rails-ai-context 5.20.0 → 5.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -0
- data/lib/rails_ai_context/app_kind.rb +23 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +63 -3
- data/lib/rails_ai_context/code_reloader.rb +79 -0
- data/lib/rails_ai_context/introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +20 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +15 -1
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +15 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +67 -6
- data/lib/rails_ai_context/introspectors/job_introspector.rb +81 -2
- data/lib/rails_ai_context/introspectors/model_introspector.rb +25 -6
- data/lib/rails_ai_context/introspectors/route_introspector.rb +5 -1
- data/lib/rails_ai_context/introspectors/static_tier.rb +9 -0
- data/lib/rails_ai_context/live_reload.rb +7 -2
- data/lib/rails_ai_context/resources.rb +7 -0
- data/lib/rails_ai_context/schema_adapter.rb +85 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +7 -6
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +5 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +7 -0
- data/lib/rails_ai_context/server.rb +45 -5
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -1
- data/lib/rails_ai_context/tools/get_active_support.rb +16 -0
- data/lib/rails_ai_context/tools/get_engines.rb +3 -1
- data/lib/rails_ai_context/tools/get_env.rb +20 -0
- data/lib/rails_ai_context/tools/get_gems.rb +4 -1
- data/lib/rails_ai_context/tools/get_i18n.rb +3 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +4 -4
- data/lib/rails_ai_context/tools/get_mailers.rb +2 -1
- data/lib/rails_ai_context/tools/get_routes.rb +20 -11
- data/lib/rails_ai_context/tools/get_schema.rb +9 -2
- data/lib/rails_ai_context/tools/onboard.rb +5 -25
- data/lib/rails_ai_context/tools/safe_call.rb +24 -9
- data/lib/rails_ai_context/tools/section_fetch.rb +1 -3
- 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 +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 81eedfa398603e64a27f7555b4288e39a11e41093c3a06450354c2f5cb1d17c5
|
|
4
|
+
data.tar.gz: 34972bf4259e8e12695f008744111bff94ed7caa93155bd3f8352010e3134c15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c88339dc6c73a5fdb38209d69e61437a6494382c069ae943478e51a265e57b2b847d4cb89f22ef0449c024bdb43185feef7e58a5e92f199c0a0389f4822898ca
|
|
7
|
+
data.tar.gz: 2f0a283b5691f00d4f49aeed839da56af13e248f566c9a526e3926cbc0c71185cf2e99a9b809d5e316d7e1144826456bb65ea36148493b4102f44b3bf0857584
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,104 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.20.2] - 2026-08-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A long-lived process no longer answers about the app as it was at boot.**
|
|
13
|
+
The MCP server and `watch` invalidated their own caches on a file change but
|
|
14
|
+
never reloaded Rails, and `Zeitwerk#eager_load_dir` does not re-scan a
|
|
15
|
+
directory it has already loaded - so a model written after the server started
|
|
16
|
+
stayed invisible for the life of the process, and the server told callers it
|
|
17
|
+
did not exist. Routes were unaffected because `RouteIntrospector` already
|
|
18
|
+
asked `routes_reloader.execute_if_updated`. Both change handlers now run
|
|
19
|
+
Rails' own reloader first, gated on `config.enable_reloading` (the flag that
|
|
20
|
+
decides whether Rails unloads anything - `eager_load` does not), and every
|
|
21
|
+
tool call and every resource read now runs inside the executor so a reload
|
|
22
|
+
cannot unload constants while one is reading them. A failing reload leaves
|
|
23
|
+
the process alive, and a tool failure is not reported to the host app's
|
|
24
|
+
error reporter - it is handled here and returned as an isError result.
|
|
25
|
+
|
|
26
|
+
- **A `validates_with` validator no longer replaces the whole model answer.**
|
|
27
|
+
`ActiveModel::Validator` has no `#attributes` - only `EachValidator` does -
|
|
28
|
+
so one of them anywhere on a model turned `rails_get_model_details`,
|
|
29
|
+
`rails_get_callbacks` and `rails://models/X` into a single
|
|
30
|
+
"Error inspecting X" line. (#121)
|
|
31
|
+
- **Static-tier callbacks are reported.** The two tiers handed back different
|
|
32
|
+
types for one key: a Hash keyed by callback type when booted, a flat Array
|
|
33
|
+
from the listener otherwise. Every consumer filters on `is_a?(Hash)`, so
|
|
34
|
+
`--no-boot` answered "No models with callbacks found" for an app full of
|
|
35
|
+
them, and `--model X --no-boot` raised `TypeError` on a Hash lookup against
|
|
36
|
+
an Array. (#122)
|
|
37
|
+
- **`get_mailers` reports the actions Rails calls actions.** It listed
|
|
38
|
+
`instance_methods(false)`, which includes ActiveSupport's generated
|
|
39
|
+
`_run_*_callbacks` and any public helper, so an abstract `ApplicationMailer`
|
|
40
|
+
was reported as having deliverable actions. It now asks
|
|
41
|
+
`action_methods`. (#126)
|
|
42
|
+
- **One answer to which database the app runs on.** Four surfaces each carried
|
|
43
|
+
their own substitute for the internal `static_parse` marker and disagreed:
|
|
44
|
+
the generated `CLAUDE.md`, `rails_get_schema`, `rails_onboard` and
|
|
45
|
+
`rails ai:inspect` could name three different databases for one app, and
|
|
46
|
+
`onboard`'s gem loop let the last match win where the serializer's let the
|
|
47
|
+
first. They now share `RailsAiContext::SchemaAdapter`, which resolves from
|
|
48
|
+
the app's own database configuration first, then the `structure.sql` dialect
|
|
49
|
+
already parsed, then the Gemfile - order-independent. (#125)
|
|
50
|
+
- **Generated context files no longer print `Database: static_parse`.** That is
|
|
51
|
+
the internal marker for "read from db/schema.rb, not the connection";
|
|
52
|
+
`onboard` already substituted the live adapter and the serializers did
|
|
53
|
+
not. (#125)
|
|
54
|
+
- **`get_job_pattern` says what it checked.** "This app may not use background
|
|
55
|
+
jobs" was a claim about the whole app drawn from one directory, and apps
|
|
56
|
+
keeping Sidekiq workers in `app/workers/` were told they have none. (#120)
|
|
57
|
+
- **`validate`'s empty-files message advises a syntax the CLI accepts.** It
|
|
58
|
+
recommended `files:["..."]`, which the CLI reads as a literal filename. (#124)
|
|
59
|
+
- **`validate --files a.rb b.rb` validates every file.** Array parameters
|
|
60
|
+
consumed only the first token, so the rest were dropped silently and the
|
|
61
|
+
summary read "1/1 files passed" for a set containing a broken file. (#124)
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- **The static tier reads `config.api_only`.** Without a booted app the view
|
|
66
|
+
tools fell back to "no Stimulus controllers found" on an API-only app, where a
|
|
67
|
+
booted run says "Not applicable". Both were true, but only one of them stops
|
|
68
|
+
an agent adding a view layer to an app that has none by design.
|
|
69
|
+
|
|
70
|
+
## [5.20.1] - 2026-08-11
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **`config.custom_tools` no longer takes the MCP server down.** Naming a
|
|
75
|
+
`BaseTool` subclass resolved its constant, which autoloaded the class and
|
|
76
|
+
enrolled it in the same registry the built-in list is read from, so it was
|
|
77
|
+
offered to the SDK twice and rejected as a duplicate name. The server exited
|
|
78
|
+
1 with an empty stdout while the CLI kept working. Tools are now merged by
|
|
79
|
+
name; two different classes claiming one name keep the built-in and warn.
|
|
80
|
+
- **`--flag value` no longer inverts boolean parameters.** A boolean flag
|
|
81
|
+
consumed no value, so `--app-only false` set `app_only` to true and dropped
|
|
82
|
+
the `false` without a warning. Affected every boolean on every tool, and
|
|
83
|
+
`--param value` is the form the CLI docs teach.
|
|
84
|
+
- **The static tier stops answering questions it cannot answer.** `mailers`
|
|
85
|
+
reported "no mailers found" for an app with mailers, `engines` reported no
|
|
86
|
+
engines loaded, `i18n` reported one locale while listing two locale files,
|
|
87
|
+
and the Action Cable channels, deprecators, on_load hooks, cache store and
|
|
88
|
+
credentials sections vanished with no marker. Mailers, channels and locales
|
|
89
|
+
are read from source through `static_call`; the rest report
|
|
90
|
+
`[UNAVAILABLE]`. Locale files using YAML anchors are read correctly, and an
|
|
91
|
+
`I18n.default_locale` set in an initializer is honoured.
|
|
92
|
+
- **`app_only:false` lists the routes it counts.** It announced the unfiltered
|
|
93
|
+
total above a body containing only app routes, and never showed a framework
|
|
94
|
+
route. App routes are now listed first, so they cannot paginate out of sight.
|
|
95
|
+
`app_only:true` says how many routes it hid and how to see them.
|
|
96
|
+
- **One app, one route count.** Generated context files counted raw routes
|
|
97
|
+
while the tools merged each resource's `PATCH`/`PUT` pair, so `CLAUDE.md` and
|
|
98
|
+
`rails_get_routes` quoted different numbers for the same app. The totals are
|
|
99
|
+
merged at the source, and the serializers read
|
|
100
|
+
`config.excluded_route_prefixes` instead of a hardcoded copy.
|
|
101
|
+
- **`rails_get_gems` past the last page** no longer opens with "No notable gems
|
|
102
|
+
found" above its own "No items at offset N" note.
|
|
103
|
+
- The MCP startup banner reads the list off the built server, so it cannot
|
|
104
|
+
announce a different set of tools than the server answers with.
|
|
105
|
+
|
|
8
106
|
## [5.20.0] - 2026-08-11
|
|
9
107
|
|
|
10
108
|
### Security
|
|
@@ -27,5 +27,28 @@ module RailsAiContext
|
|
|
27
27
|
content = RailsAiContext::SafeFile.read(gemfile)
|
|
28
28
|
!!content&.match?(/^\s*gem\s+["']mongoid["']/)
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# An API-only app has no view layer, and saying "no Stimulus controllers
|
|
32
|
+
# found" about one invites an agent to add some. The flag is written in
|
|
33
|
+
# config/application.rb, so this answer needs no booted app.
|
|
34
|
+
#
|
|
35
|
+
# Read from the AST, not a regex: `config.api_only = true` is an
|
|
36
|
+
# assignment, which docs/INTROSPECTORS.md puts squarely in AST territory,
|
|
37
|
+
# and ConfigAssignmentListener already reports exactly this shape. A regex
|
|
38
|
+
# also has to hand-roll what the parser knows for free - comments, strings
|
|
39
|
+
# and heredocs that merely contain the text.
|
|
40
|
+
def api_only?(root)
|
|
41
|
+
path = File.join(root.to_s, "config", "application.rb")
|
|
42
|
+
return false unless File.exist?(path)
|
|
43
|
+
return false unless RailsAiContext::SafeFile.read(path)
|
|
44
|
+
|
|
45
|
+
walked = Introspectors::SourceIntrospector.walk(
|
|
46
|
+
path, { config: Introspectors::Listeners::ConfigAssignmentListener }
|
|
47
|
+
)
|
|
48
|
+
hit = Array(walked[:config]).find { |entry| entry[:path] == [ :api_only ] }
|
|
49
|
+
!hit.nil? && hit[:value] == true
|
|
50
|
+
rescue StandardError, ScriptError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
30
53
|
end
|
|
31
54
|
end
|
|
@@ -15,6 +15,13 @@ module RailsAiContext
|
|
|
15
15
|
class ToolNotFoundError < StandardError; end
|
|
16
16
|
class InvalidArgumentError < StandardError; end
|
|
17
17
|
|
|
18
|
+
# The only words that read as true, and every word a boolean flag will
|
|
19
|
+
# consume as its value. Kept together so the two cannot drift: a word
|
|
20
|
+
# accepted here but missing from TRUTHY would silently mean false.
|
|
21
|
+
TRUTHY_WORDS = %w[true 1 yes].freeze
|
|
22
|
+
FALSEY_WORDS = %w[false 0 no].freeze
|
|
23
|
+
BOOLEAN_WORDS = (TRUTHY_WORDS + FALSEY_WORDS).freeze
|
|
24
|
+
|
|
18
25
|
attr_reader :tool_class, :raw_args, :json_mode, :error
|
|
19
26
|
|
|
20
27
|
def initialize(tool_name, raw_args, json_mode: false)
|
|
@@ -204,13 +211,51 @@ module RailsAiContext
|
|
|
204
211
|
if arg.include?("=")
|
|
205
212
|
key, value = arg.sub("--", "").split("=", 2)
|
|
206
213
|
key = key.tr("-", "_").to_sym
|
|
207
|
-
|
|
214
|
+
prop = properties[key] || {}
|
|
215
|
+
if prop[:type] == "array"
|
|
216
|
+
# `--files=a.rb b.rb` is the same call as `--files a.rb b.rb`;
|
|
217
|
+
# honouring only one of the two spellings docs/CLI.md teaches
|
|
218
|
+
# left the other still dropping every file after the first.
|
|
219
|
+
trailing = collect_array_values(args, i + 1)
|
|
220
|
+
result[key] = Array(coerce_value(value, prop)) + trailing
|
|
221
|
+
i += 1 + values_consumed(args, i + 1)
|
|
222
|
+
next
|
|
223
|
+
end
|
|
224
|
+
result[key] = coerce_value(value, prop)
|
|
208
225
|
else
|
|
209
226
|
key = arg.sub("--", "").tr("-", "_").to_sym
|
|
210
227
|
prop = properties[key] || {}
|
|
211
228
|
|
|
212
229
|
if prop[:type] == "boolean"
|
|
213
|
-
|
|
230
|
+
# `--flag false` is the form docs/CLI.md teaches for every
|
|
231
|
+
# other param, so a boolean has to honour it too. Only an
|
|
232
|
+
# explicit boolean word is consumed - anything else stays a
|
|
233
|
+
# separate argument and the flag means true.
|
|
234
|
+
nxt = args[i + 1]
|
|
235
|
+
if nxt && BOOLEAN_WORDS.include?(nxt.downcase)
|
|
236
|
+
result[key] = coerce_value(nxt, prop)
|
|
237
|
+
i += 2
|
|
238
|
+
else
|
|
239
|
+
result[key] = true
|
|
240
|
+
i += 1
|
|
241
|
+
end
|
|
242
|
+
next
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if prop[:type] == "array"
|
|
246
|
+
values = collect_array_values(args, i + 1)
|
|
247
|
+
unless values.empty?
|
|
248
|
+
result[key] = values
|
|
249
|
+
i += 1 + values_consumed(args, i + 1)
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A bare `--files` with nothing after it is a mistake, not a
|
|
255
|
+
# request for `files: true` - an array param holding a Boolean
|
|
256
|
+
# reaches the tool as a type it never accepts.
|
|
257
|
+
if prop[:type] == "array"
|
|
258
|
+
result[key] = []
|
|
214
259
|
i += 1
|
|
215
260
|
next
|
|
216
261
|
end
|
|
@@ -241,13 +286,28 @@ module RailsAiContext
|
|
|
241
286
|
result
|
|
242
287
|
end
|
|
243
288
|
|
|
289
|
+
# Tokens belonging to an array flag: everything up to the next flag,
|
|
290
|
+
# stopping at a rake-style `key=value` token so `--include a model=Post`
|
|
291
|
+
# does not swallow the second parameter. Each token is still
|
|
292
|
+
# comma-split, so the documented `a.rb,b.rb` form keeps working and
|
|
293
|
+
# mixing the two spellings does not fabricate a path.
|
|
294
|
+
def collect_array_values(args, from)
|
|
295
|
+
args[from..].to_a
|
|
296
|
+
.take_while { |a| !a.start_with?("--") && !a.include?("=") }
|
|
297
|
+
.flat_map { |a| Array(coerce_value(a, { type: "array" })) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def values_consumed(args, from)
|
|
301
|
+
args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }.size
|
|
302
|
+
end
|
|
303
|
+
|
|
244
304
|
# Coerce a string value to the type specified in the JSON Schema property.
|
|
245
305
|
def coerce_value(raw, property_schema)
|
|
246
306
|
case property_schema[:type]
|
|
247
307
|
when "integer"
|
|
248
308
|
raw.to_i
|
|
249
309
|
when "boolean"
|
|
250
|
-
|
|
310
|
+
TRUTHY_WORDS.include?(raw.to_s.downcase)
|
|
251
311
|
when "array"
|
|
252
312
|
raw.is_a?(Array) ? raw : raw.to_s.split(",").map(&:strip)
|
|
253
313
|
else
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# Re-runs Rails' own code reloader so a long-lived process sees files written
|
|
5
|
+
# after it booted.
|
|
6
|
+
#
|
|
7
|
+
# Clearing the gem's caches is not enough on its own. Introspectors reach the
|
|
8
|
+
# app through constants, and the eager loading they trigger
|
|
9
|
+
# (`Zeitwerk::Loader#eager_load_dir`) is idempotent per process - a directory
|
|
10
|
+
# already loaded is never re-scanned, so a model added after boot stays
|
|
11
|
+
# invisible for the life of the server. Routes never had the problem because
|
|
12
|
+
# RouteIntrospector asks `routes_reloader.execute_if_updated` every call.
|
|
13
|
+
module CodeReloader
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Reload the app's autoloaded code. Returns whether a reload actually ran,
|
|
17
|
+
# so callers can say what they did instead of guessing.
|
|
18
|
+
def reload!
|
|
19
|
+
return false unless reloadable?
|
|
20
|
+
|
|
21
|
+
Rails.application.reloader.reload!
|
|
22
|
+
true
|
|
23
|
+
rescue StandardError, ScriptError => e
|
|
24
|
+
# A broken file mid-edit is the common case. Zeitwerk reloads by
|
|
25
|
+
# unload-then-setup, so a raise can leave constants already unloaded -
|
|
26
|
+
# they re-autoload lazily on next reference. Either way a running server
|
|
27
|
+
# must survive it.
|
|
28
|
+
$stderr.puts "[rails-ai-context] code reload failed: #{e.class}: #{e.message}" if ENV["DEBUG"]
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Run a block with the sharing lock held, so a reload cannot unload
|
|
33
|
+
# constants underneath it.
|
|
34
|
+
#
|
|
35
|
+
# `Reloader#class_unload!` takes the unload lock through
|
|
36
|
+
# ActiveSupport::Dependencies.interlock, but that only blocks threads
|
|
37
|
+
# holding the sharing lock - which is acquired inside `executor.wrap`.
|
|
38
|
+
# Nothing here wrapped anything, so the Listen thread could clear
|
|
39
|
+
# DescendantsTracker while a tool call was midway through reading
|
|
40
|
+
# `ActiveRecord::Base.descendants`, returning a short list with no
|
|
41
|
+
# exception for the per-section rescue to notice.
|
|
42
|
+
#
|
|
43
|
+
# Only taken when a reload could actually happen; everywhere else this is
|
|
44
|
+
# a plain yield.
|
|
45
|
+
def with_app_code
|
|
46
|
+
return yield unless reloadable?
|
|
47
|
+
|
|
48
|
+
app = Rails.application
|
|
49
|
+
return yield unless app.respond_to?(:executor)
|
|
50
|
+
|
|
51
|
+
app.executor.wrap { yield }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `enable_reloading` is the flag that decides whether Rails unloads
|
|
55
|
+
# anything: with it off, the finisher registers no class_unload callback,
|
|
56
|
+
# so `reload!` runs the prepare callbacks and returns having reloaded
|
|
57
|
+
# nothing. Gating on `eager_load` instead reported success for every
|
|
58
|
+
# eager_load=false + cache_classes=true environment (stock `test`, a
|
|
59
|
+
# cache-classes staging container) - a reload that never happened,
|
|
60
|
+
# announced as one.
|
|
61
|
+
def reloadable?
|
|
62
|
+
return false if RailsAiContext.static_tier?
|
|
63
|
+
return false unless defined?(Rails) && Rails.respond_to?(:application)
|
|
64
|
+
|
|
65
|
+
app = Rails.application
|
|
66
|
+
return false unless app.respond_to?(:reloader) && app.respond_to?(:config)
|
|
67
|
+
|
|
68
|
+
config = app.config
|
|
69
|
+
# Rails 7.1+ names it enable_reloading; older releases only have
|
|
70
|
+
# cache_classes, which is its inverse.
|
|
71
|
+
return config.enable_reloading if config.respond_to?(:enable_reloading)
|
|
72
|
+
return !config.cache_classes if config.respond_to?(:cache_classes)
|
|
73
|
+
|
|
74
|
+
false
|
|
75
|
+
rescue StandardError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -122,9 +122,7 @@ module RailsAiContext
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def unavailable_reason
|
|
125
|
-
|
|
126
|
-
base = "requires a booted Rails app"
|
|
127
|
-
reason ? "#{base} (#{reason})" : base
|
|
125
|
+
Introspectors::StaticTier.unavailable_reason
|
|
128
126
|
end
|
|
129
127
|
|
|
130
128
|
def rails_version
|
|
@@ -8,7 +8,7 @@ module RailsAiContext
|
|
|
8
8
|
# Covers RAILS_NERVOUS_SYSTEM.md §17 (ActiveSupport).
|
|
9
9
|
class ActiveSupportIntrospector
|
|
10
10
|
extend StaticTier
|
|
11
|
-
static_tier :
|
|
11
|
+
static_tier :alternate_source
|
|
12
12
|
|
|
13
13
|
attr_reader :app
|
|
14
14
|
|
|
@@ -30,6 +30,25 @@ module RailsAiContext
|
|
|
30
30
|
{ error: e.message }
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# Concerns, MessageVerifier usage and tagged logging are read off disk.
|
|
34
|
+
# The registered deprecators, the subscribed load hooks and the cache
|
|
35
|
+
# store only exist in a running process; an empty list for them renders
|
|
36
|
+
# as "this app has none", so they refuse instead.
|
|
37
|
+
def static_call
|
|
38
|
+
unavailable = { unavailable: StaticTier.unavailable_reason }
|
|
39
|
+
{
|
|
40
|
+
concerns: extract_concerns,
|
|
41
|
+
deprecators: unavailable,
|
|
42
|
+
message_verifier_usage: extract_message_verifier_usage,
|
|
43
|
+
tagged_logging: detect_tagged_logging,
|
|
44
|
+
on_load_hooks: unavailable,
|
|
45
|
+
cache_usage: unavailable
|
|
46
|
+
}
|
|
47
|
+
rescue => e
|
|
48
|
+
$stderr.puts "[rails-ai-context] ActiveSupportIntrospector#static_call failed: #{e.message}" if ENV["DEBUG"]
|
|
49
|
+
{ error: e.message }
|
|
50
|
+
end
|
|
51
|
+
|
|
33
52
|
private
|
|
34
53
|
|
|
35
54
|
def root
|
|
@@ -6,7 +6,7 @@ module RailsAiContext
|
|
|
6
6
|
# versioning patterns, rate limiting.
|
|
7
7
|
class ApiIntrospector
|
|
8
8
|
extend StaticTier
|
|
9
|
-
static_tier :
|
|
9
|
+
static_tier :alternate_source
|
|
10
10
|
|
|
11
11
|
attr_reader :app
|
|
12
12
|
|
|
@@ -14,6 +14,20 @@ module RailsAiContext
|
|
|
14
14
|
@app = app
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Everything but api_only needs a booted app, but api_only itself is a
|
|
18
|
+
# plain assignment in config/application.rb - and the view tools already
|
|
19
|
+
# read it there. Leaving this section wholly unavailable meant one
|
|
20
|
+
# process answering "this is an API-only app" from get_stimulus and
|
|
21
|
+
# "cannot say" from get_api.
|
|
22
|
+
def static_call
|
|
23
|
+
{
|
|
24
|
+
api_only: AppKind.api_only?(app.root),
|
|
25
|
+
unavailable_sections: StaticTier.unavailable_reason
|
|
26
|
+
}
|
|
27
|
+
rescue StandardError
|
|
28
|
+
{ unavailable: StaticTier.unavailable_reason }
|
|
29
|
+
end
|
|
30
|
+
|
|
17
31
|
def call
|
|
18
32
|
{
|
|
19
33
|
api_only: app.config.api_only,
|
|
@@ -6,7 +6,7 @@ module RailsAiContext
|
|
|
6
6
|
# Identifies well-known engines and provides context about what each does.
|
|
7
7
|
class EngineIntrospector
|
|
8
8
|
extend StaticTier
|
|
9
|
-
static_tier :
|
|
9
|
+
static_tier :alternate_source
|
|
10
10
|
|
|
11
11
|
attr_reader :app
|
|
12
12
|
|
|
@@ -50,6 +50,20 @@ module RailsAiContext
|
|
|
50
50
|
{ error: e.message }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# config/routes.rb is a file, so mounts read the same either way. Which
|
|
54
|
+
# engine classes a process loaded is only knowable from that process -
|
|
55
|
+
# and testing `defined?(Rails::Engine)` instead of the tier answered from
|
|
56
|
+
# the half-finished boot that put us here, which is the common way into
|
|
57
|
+
# the static tier.
|
|
58
|
+
def static_call
|
|
59
|
+
{
|
|
60
|
+
mounted_engines: discover_mounted_engines,
|
|
61
|
+
rails_engines: { unavailable: StaticTier.unavailable_reason }
|
|
62
|
+
}
|
|
63
|
+
rescue => e
|
|
64
|
+
{ error: e.message }
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
private
|
|
54
68
|
|
|
55
69
|
def root
|
|
@@ -7,7 +7,12 @@ module RailsAiContext
|
|
|
7
7
|
# Discovers internationalization setup: locales, backends, key counts.
|
|
8
8
|
class I18nIntrospector
|
|
9
9
|
extend StaticTier
|
|
10
|
-
static_tier :
|
|
10
|
+
static_tier :alternate_source
|
|
11
|
+
|
|
12
|
+
# Both spellings apps use: `config.i18n.default_locale = :es` in
|
|
13
|
+
# application.rb or an environment file, and a bare
|
|
14
|
+
# `I18n.default_locale = :es` in an initializer.
|
|
15
|
+
DEFAULT_LOCALE_ASSIGNMENT = /(?:config\.i18n|I18n)\.default_locale\s*=\s*[:"']([\w-]+)/
|
|
11
16
|
|
|
12
17
|
attr_reader :app
|
|
13
18
|
|
|
@@ -30,8 +35,65 @@ module RailsAiContext
|
|
|
30
35
|
{ error: e.message }
|
|
31
36
|
end
|
|
32
37
|
|
|
38
|
+
# The locale files are the same files either way; only the list of
|
|
39
|
+
# locales and the default came from a running I18n. Read both from disk
|
|
40
|
+
# rather than report the library's own defaults as the app's.
|
|
41
|
+
def static_call
|
|
42
|
+
locales = locales_from_files
|
|
43
|
+
default = default_locale_from_config
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
default_locale: default,
|
|
47
|
+
available_locales: locales,
|
|
48
|
+
backend: nil,
|
|
49
|
+
locale_files: extract_locale_files,
|
|
50
|
+
total_locale_files: count_locale_files,
|
|
51
|
+
locale_coverage: detect_locale_coverage(locales: locales.map(&:to_sym), default: default.to_sym)
|
|
52
|
+
}.merge(detect_fallback_config)
|
|
53
|
+
rescue => e
|
|
54
|
+
{ error: e.message }
|
|
55
|
+
end
|
|
56
|
+
|
|
33
57
|
private
|
|
34
58
|
|
|
59
|
+
# Every top-level key across config/locales - the same population Rails
|
|
60
|
+
# builds available_locales from.
|
|
61
|
+
def locales_from_files
|
|
62
|
+
dir = File.join(root, "config/locales")
|
|
63
|
+
return [] unless Dir.exist?(dir)
|
|
64
|
+
|
|
65
|
+
Dir.glob(File.join(dir, "**/*.{yml,yaml}")).flat_map do |path|
|
|
66
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
67
|
+
next [] unless content
|
|
68
|
+
|
|
69
|
+
# aliases: true, like extract_locale_files. Sharing formats through a
|
|
70
|
+
# YAML anchor is ordinary, and without the flag Psych raises, the
|
|
71
|
+
# rescue swallows it, and every locale in that file disappears while
|
|
72
|
+
# the Locale Files section still lists it.
|
|
73
|
+
data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
|
|
74
|
+
data.is_a?(Hash) ? data.keys.map(&:to_s) : []
|
|
75
|
+
rescue StandardError
|
|
76
|
+
[]
|
|
77
|
+
end.uniq.sort
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Rails' own default is :en, so "en" is the right answer when the app
|
|
81
|
+
# never says otherwise - not a guess.
|
|
82
|
+
def default_locale_from_config
|
|
83
|
+
candidates = [ File.join(root, "config", "application.rb") ] +
|
|
84
|
+
Dir.glob(File.join(root, "config", "environments", "*.rb")) +
|
|
85
|
+
Dir.glob(File.join(root, "config", "initializers", "*.rb"))
|
|
86
|
+
|
|
87
|
+
candidates.each do |path|
|
|
88
|
+
next unless File.exist?(path)
|
|
89
|
+
|
|
90
|
+
content = RailsAiContext::SafeFile.read(path)
|
|
91
|
+
match = content&.match(DEFAULT_LOCALE_ASSIGNMENT)
|
|
92
|
+
return match[1] if match
|
|
93
|
+
end
|
|
94
|
+
"en"
|
|
95
|
+
end
|
|
96
|
+
|
|
35
97
|
def root
|
|
36
98
|
app.root.to_s
|
|
37
99
|
end
|
|
@@ -77,8 +139,7 @@ module RailsAiContext
|
|
|
77
139
|
{}
|
|
78
140
|
end
|
|
79
141
|
|
|
80
|
-
def detect_locale_coverage
|
|
81
|
-
locales = I18n.available_locales
|
|
142
|
+
def detect_locale_coverage(locales: I18n.available_locales, default: I18n.default_locale)
|
|
82
143
|
return {} if locales.size < 2
|
|
83
144
|
|
|
84
145
|
# Coverage is the share of the default locale's keys that the other
|
|
@@ -86,8 +147,8 @@ module RailsAiContext
|
|
|
86
147
|
# for a locale that translates few default keys but adds many of its
|
|
87
148
|
# own - the one number a translator must not be told is fine.
|
|
88
149
|
coverage = {}
|
|
89
|
-
default_keys = key_paths_for_locale(
|
|
90
|
-
locales.reject { |l| l ==
|
|
150
|
+
default_keys = key_paths_for_locale(default)
|
|
151
|
+
locales.reject { |l| l == default }.each do |locale|
|
|
91
152
|
locale_keys = key_paths_for_locale(locale)
|
|
92
153
|
translated = (default_keys & locale_keys).size
|
|
93
154
|
coverage[locale.to_s] = {
|
|
@@ -110,7 +171,7 @@ module RailsAiContext
|
|
|
110
171
|
find_locale_paths(locale).flat_map do |path|
|
|
111
172
|
content = RailsAiContext::SafeFile.read(path)
|
|
112
173
|
next [] unless content
|
|
113
|
-
data = YAML.safe_load(content, permitted_classes: [ Symbol ])
|
|
174
|
+
data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
|
|
114
175
|
next [] unless data.is_a?(Hash)
|
|
115
176
|
|
|
116
177
|
# A locale root may be written `en:` or `:en:` - both load, and both
|
|
@@ -6,7 +6,7 @@ module RailsAiContext
|
|
|
6
6
|
# and Action Cable channels.
|
|
7
7
|
class JobIntrospector
|
|
8
8
|
extend StaticTier
|
|
9
|
-
static_tier :
|
|
9
|
+
static_tier :alternate_source
|
|
10
10
|
|
|
11
11
|
CHANNEL_MACROS = %i[identified_by stream_from stream_for periodically].freeze
|
|
12
12
|
|
|
@@ -31,6 +31,19 @@ module RailsAiContext
|
|
|
31
31
|
}
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Mailers and channels are ordinary classes in ordinary directories, so
|
|
35
|
+
# the answer is the same with or without a booted app. Only the way in
|
|
36
|
+
# differs: descendants when Rails is up, the AST when it is not.
|
|
37
|
+
def static_call
|
|
38
|
+
{
|
|
39
|
+
jobs: extract_jobs_from_source,
|
|
40
|
+
mailers: extract_mailers_from_source,
|
|
41
|
+
channels: extract_channels_from_source,
|
|
42
|
+
recurring_jobs: extract_solid_queue_recurring,
|
|
43
|
+
sidekiq_config: extract_sidekiq_config
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
34
47
|
private
|
|
35
48
|
|
|
36
49
|
def extract_jobs
|
|
@@ -221,7 +234,14 @@ module RailsAiContext
|
|
|
221
234
|
ActionMailer::Base.descendants.filter_map do |mailer|
|
|
222
235
|
next if mailer.name.nil?
|
|
223
236
|
|
|
224
|
-
|
|
237
|
+
# Rails' own answer: it subtracts internal and inherited methods, so
|
|
238
|
+
# an abstract ApplicationMailer reports none and ActiveSupport's
|
|
239
|
+
# generated `_run_*_callbacks` never appears. `instance_methods(false)`
|
|
240
|
+
# listed both as deliverable actions. Called bare, like
|
|
241
|
+
# ControllerIntrospector does - AbstractController::Base has defined
|
|
242
|
+
# it across the whole supported range, so a fallback here would only
|
|
243
|
+
# be unreachable code holding the old bug.
|
|
244
|
+
actions = mailer.action_methods.to_a.map(&:to_s).sort
|
|
225
245
|
next if actions.empty?
|
|
226
246
|
|
|
227
247
|
{
|
|
@@ -235,6 +255,65 @@ module RailsAiContext
|
|
|
235
255
|
[]
|
|
236
256
|
end
|
|
237
257
|
|
|
258
|
+
# A mailer's actions are its public instance methods. The booted side asks
|
|
259
|
+
# `action_methods`, which also subtracts inherited and internal ones - the
|
|
260
|
+
# AST cannot see that, so a public helper on a concrete mailer is still
|
|
261
|
+
# listed here and not there.
|
|
262
|
+
def extract_mailers_from_source
|
|
263
|
+
source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
|
|
264
|
+
next if name == "ApplicationMailer"
|
|
265
|
+
|
|
266
|
+
actions = methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }
|
|
267
|
+
.map { |m| m[:name] }.sort
|
|
268
|
+
next if actions.empty?
|
|
269
|
+
|
|
270
|
+
{ name: name, actions: actions, confidence: RailsAiContext::Confidence::STATIC }
|
|
271
|
+
end.sort_by { |m| m[:name] }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def extract_channels_from_source
|
|
275
|
+
# ApplicationCable holds the base Channel and Connection, neither of
|
|
276
|
+
# which is a channel of the app's own.
|
|
277
|
+
source_classes(File.join(app.root, "app", "channels")).filter_map do |name, methods|
|
|
278
|
+
next if name.start_with?("ApplicationCable::")
|
|
279
|
+
|
|
280
|
+
stream_methods = methods.select { |m| m[:scope] == :instance }
|
|
281
|
+
.map { |m| m[:name] }
|
|
282
|
+
.select { |m| m.start_with?("stream_") || m == "subscribed" }
|
|
283
|
+
{ name: name, stream_methods: stream_methods, confidence: RailsAiContext::Confidence::STATIC }
|
|
284
|
+
end.sort_by { |c| c[:name] }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Class name plus method list for every .rb under `dir`, read from the
|
|
288
|
+
# AST. Yields nothing when the directory is absent.
|
|
289
|
+
#
|
|
290
|
+
# The name comes from the path, not the AST: Zeitwerk requires the two to
|
|
291
|
+
# agree, and only the path carries the namespace. Reading `class Channel`
|
|
292
|
+
# out of `application_cable/channel.rb` yields "Channel", which matches
|
|
293
|
+
# no base-class filter and no name the booted app would report.
|
|
294
|
+
def source_classes(dir)
|
|
295
|
+
return [] unless Dir.exist?(dir)
|
|
296
|
+
|
|
297
|
+
Dir.glob(File.join(dir, "**/*.rb")).sort.filter_map do |path|
|
|
298
|
+
next unless File.exist?(path) && File.size(path) > 0
|
|
299
|
+
next if File.size(path) > RailsAiContext.configuration.max_file_size
|
|
300
|
+
# Rails adds app/*/concerns as its own autoload root, so what lives
|
|
301
|
+
# there is a mixin, not a mailer or a channel - and naming it from
|
|
302
|
+
# the path would invent a `Concerns::` namespace that never exists.
|
|
303
|
+
next if path.sub("#{dir}/", "").start_with?("concerns/")
|
|
304
|
+
|
|
305
|
+
walked = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener })
|
|
306
|
+
[ constant_name_for(path, dir), walked[:methods] || [] ]
|
|
307
|
+
rescue StandardError, ScriptError => e
|
|
308
|
+
$stderr.puts "[rails-ai-context] source_classes failed for #{path}: #{e.message}" if ENV["DEBUG"]
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def constant_name_for(path, dir)
|
|
314
|
+
path.sub("#{dir}/", "").sub(/\.rb\z/, "").camelize
|
|
315
|
+
end
|
|
316
|
+
|
|
238
317
|
def extract_channels
|
|
239
318
|
return [] unless defined?(ActionCable::Channel::Base)
|
|
240
319
|
|