rails-ai-context 5.20.2 → 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 +73 -0
- data/gemfiles/prism_floor.gemfile +2 -0
- data/gemfiles/prism_head.gemfile +2 -0
- data/lib/rails_ai_context/introspector.rb +37 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +30 -6
- 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/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_job_pattern.rb +32 -6
- data/lib/rails_ai_context/tools/get_routes.rb +6 -0
- 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 +2 -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,79 @@ 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
|
+
|
|
8
81
|
## [5.20.2] - 2026-08-11
|
|
9
82
|
|
|
10
83
|
### 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
|
|
@@ -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
|
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -8,6 +8,10 @@ module RailsAiContext
|
|
|
8
8
|
extend StaticTier
|
|
9
9
|
static_tier :alternate_source
|
|
10
10
|
|
|
11
|
+
# A drawn file can draw again. Rails allows it; this stops a cycle of
|
|
12
|
+
# symlinked or mutually-drawing files from walking forever.
|
|
13
|
+
MAX_DRAW_DEPTH = 5
|
|
14
|
+
|
|
11
15
|
attr_reader :app
|
|
12
16
|
|
|
13
17
|
def initialize(app)
|
|
@@ -38,7 +42,8 @@ module RailsAiContext
|
|
|
38
42
|
{ error: e.message }
|
|
39
43
|
end
|
|
40
44
|
|
|
41
|
-
# Static tier: answer route questions from config/routes.rb
|
|
45
|
+
# Static tier: answer route questions from config/routes.rb and the
|
|
46
|
+
# files it draws.
|
|
42
47
|
# Output mirrors the runtime shape exactly so tools, resources, and
|
|
43
48
|
# serializers need no static-awareness of their own. Routes behind
|
|
44
49
|
# dynamic constructs (devise_for, draw, concerns) are counted in
|
|
@@ -47,21 +52,23 @@ module RailsAiContext
|
|
|
47
52
|
routes_path = File.join(app.root.to_s, "config", "routes.rb")
|
|
48
53
|
return { error: "config/routes.rb not found in #{app.root}" } unless File.exist?(routes_path)
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
routes: -> { Listeners::RoutesDslListener.new },
|
|
52
|
-
mounts: -> { Listeners::MountListener.new }
|
|
53
|
-
})
|
|
54
|
-
records = ast[:routes] || []
|
|
55
|
+
records, mounts, files = walk_routes_file(routes_path)
|
|
55
56
|
entries = records.select { |r| r[:type] == :route }
|
|
56
|
-
|
|
57
|
+
# A followed `draw` is no longer unexpanded - its routes are in the
|
|
58
|
+
# list above. Counting it would overstate what is missing by exactly
|
|
59
|
+
# the number of files this pass just read.
|
|
60
|
+
dynamic = records.count { |r| r[:type] == :dynamic && !r[:followed] }
|
|
57
61
|
|
|
58
62
|
result = {
|
|
59
|
-
|
|
63
|
+
# Merged, for the reason `call` gives above: a raw total here made the
|
|
64
|
+
# generated files say "8 total" where rails_get_routes, which merges
|
|
65
|
+
# for itself, said 7 on the same `resources :posts`.
|
|
66
|
+
total_routes: Tools::BaseTool.dedupe_put_patch_routes(entries).size,
|
|
60
67
|
by_controller: group_by_controller(entries),
|
|
61
68
|
api_namespaces: static_api_namespaces(entries),
|
|
62
|
-
mounted_engines:
|
|
69
|
+
mounted_engines: mounts.map { |m| { engine: m[:engine], path: m[:path] } },
|
|
63
70
|
root_route: static_root_route(entries),
|
|
64
|
-
note: "Parsed statically from
|
|
71
|
+
note: "Parsed statically from #{static_sources_phrase(files)} (app not booted)",
|
|
65
72
|
confidence: Confidence::STATIC
|
|
66
73
|
}
|
|
67
74
|
result[:dynamic_routes] = dynamic if dynamic.positive?
|
|
@@ -72,6 +79,95 @@ module RailsAiContext
|
|
|
72
79
|
|
|
73
80
|
private
|
|
74
81
|
|
|
82
|
+
# An app that splits its routing table with `draw` keeps most of it in
|
|
83
|
+
# config/routes/*.rb, and reading config/routes.rb alone answered 94 on a
|
|
84
|
+
# 723-route app with nothing saying the count was partial. Rails resolves
|
|
85
|
+
# `draw(:admin)` to config/routes/admin.rb by literal path, so following
|
|
86
|
+
# it is a plain file read.
|
|
87
|
+
#
|
|
88
|
+
# Returns the merged records, mounts, and the files actually read.
|
|
89
|
+
def walk_routes_file(path, already_read = [], depth = 0)
|
|
90
|
+
return [ [], [], [] ] if depth > MAX_DRAW_DEPTH
|
|
91
|
+
|
|
92
|
+
already_read << path
|
|
93
|
+
ast = SourceIntrospector.walk(path, {
|
|
94
|
+
routes: -> { Listeners::RoutesDslListener.new },
|
|
95
|
+
mounts: -> { Listeners::MountListener.new }
|
|
96
|
+
})
|
|
97
|
+
records = ast[:routes] || []
|
|
98
|
+
mounts = ast[:mounts] || []
|
|
99
|
+
files = [ path ]
|
|
100
|
+
|
|
101
|
+
records.select { |r| r[:type] == :dynamic && r[:macro] == :draw }.each do |record|
|
|
102
|
+
target = draw_target_path(record[:target])
|
|
103
|
+
next unless target
|
|
104
|
+
|
|
105
|
+
# Two files can draw the same third one, and a cycle brings the walk
|
|
106
|
+
# back to a file it started at. Both mean the routes are already in
|
|
107
|
+
# the list, so the draw is expanded even though this branch will not
|
|
108
|
+
# read it again - and this is also what stops the recursion.
|
|
109
|
+
if already_read.include?(target)
|
|
110
|
+
record[:followed] = true
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sub_records, sub_mounts, sub_files = walk_draw_target(target, already_read, depth)
|
|
115
|
+
# Only the depth cap and an unreadable file get here, and both mean
|
|
116
|
+
# routes are missing. Marking the draw followed would drop the caveat
|
|
117
|
+
# precisely where it is needed.
|
|
118
|
+
next if sub_files.empty?
|
|
119
|
+
|
|
120
|
+
record[:followed] = true
|
|
121
|
+
records.concat(sub_records)
|
|
122
|
+
mounts.concat(sub_mounts)
|
|
123
|
+
files.concat(sub_files)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
[ records, mounts, files ]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A drawn file that cannot be parsed - over AstCache's size ceiling, or
|
|
130
|
+
# syntax-broken - must cost its own routes, not the routing table. Before
|
|
131
|
+
# this walk existed only config/routes.rb could fail the whole section;
|
|
132
|
+
# letting the raise through would hand that power to every file it draws.
|
|
133
|
+
# The draw stays unmarked, so the count already says routes are missing.
|
|
134
|
+
def walk_draw_target(target, already_read, depth)
|
|
135
|
+
walk_routes_file(target, already_read, depth + 1)
|
|
136
|
+
rescue StandardError, ScriptError => e
|
|
137
|
+
$stderr.puts "[rails-ai-context] draw target #{target} skipped: #{e.message}" if ENV["DEBUG"]
|
|
138
|
+
[ [], [], [] ]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# `draw(:"admin/users")` is legal and resolves under config/routes/, but
|
|
142
|
+
# the name reaches here from source text, so the resolved path has to be
|
|
143
|
+
# confirmed inside that directory before it is read.
|
|
144
|
+
#
|
|
145
|
+
# Resolved with realpath, like safe_glob_realpath: expand_path folds
|
|
146
|
+
# `..` without following links, so a symlink under config/routes/ was
|
|
147
|
+
# enough to read a file anywhere on disk.
|
|
148
|
+
def draw_target_path(target)
|
|
149
|
+
return nil if target.nil? || target.to_s.empty?
|
|
150
|
+
|
|
151
|
+
dir = File.realpath(File.join(app.root.to_s, "config", "routes"))
|
|
152
|
+
candidate = File.realpath(File.join(dir, "#{target}.rb"))
|
|
153
|
+
return nil unless candidate.start_with?("#{dir}#{File::SEPARATOR}")
|
|
154
|
+
return nil unless File.file?(candidate)
|
|
155
|
+
|
|
156
|
+
candidate
|
|
157
|
+
rescue SystemCallError
|
|
158
|
+
# No config/routes/ at all, a dangling symlink, or a name that resolves
|
|
159
|
+
# to nothing. None of them is a route this pass can read.
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def static_sources_phrase(files)
|
|
164
|
+
root = "#{app.root}#{File::SEPARATOR}"
|
|
165
|
+
names = files.map { |f| f.delete_prefix(root) }
|
|
166
|
+
return names.first if names.size == 1
|
|
167
|
+
|
|
168
|
+
"#{names.first} and #{CountPhrase.call(names.size - 1, "file")} it draws"
|
|
169
|
+
end
|
|
170
|
+
|
|
75
171
|
def extract_routes
|
|
76
172
|
# Force Rails to reload routes if routes.rb has changed
|
|
77
173
|
app.routes_reloader&.execute_if_updated rescue nil
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# One answer to "how much of the routing table is this number", for every
|
|
5
|
+
# surface that prints a route count.
|
|
6
|
+
#
|
|
7
|
+
# `RouteIntrospector` records the constructs it refused to fabricate -
|
|
8
|
+
# `devise_for`, a `draw` whose target is computed or too large to parse - in
|
|
9
|
+
# `:dynamic_routes`, and nothing read it. So `rails_get_routes`, `CLAUDE.md`,
|
|
10
|
+
# the Cursor and Copilot rule files, `rails_onboard` and the rake summary all
|
|
11
|
+
# quoted 94 routes on a 723-route app with nothing saying the count was
|
|
12
|
+
# partial. Nine call sites each having to remember is what produced that;
|
|
13
|
+
# this is the seam they share.
|
|
14
|
+
module RouteCoverage
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# A suffix rather than a predicate, so no call site needs a conditional of
|
|
18
|
+
# its own - that shape is what let nine of them forget.
|
|
19
|
+
#
|
|
20
|
+
# @param routes [Hash] the :routes section of an introspection context
|
|
21
|
+
# @return [String] a leading-comma clause naming what the count leaves out,
|
|
22
|
+
# or "" when the count is the whole table
|
|
23
|
+
def suffix(routes)
|
|
24
|
+
return "" unless routes.is_a?(Hash) && !routes[:error]
|
|
25
|
+
|
|
26
|
+
unexpanded = routes[:dynamic_routes].to_i
|
|
27
|
+
return "" unless unexpanded.positive?
|
|
28
|
+
|
|
29
|
+
", #{CountPhrase.call(unexpanded, "dynamic construct")} not expanded"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -50,7 +50,7 @@ module RailsAiContext
|
|
|
50
50
|
lines << "- Models: #{models.size}" if models.is_a?(Hash) && !models[:error]
|
|
51
51
|
|
|
52
52
|
routes = context[:routes]
|
|
53
|
-
lines << "- Routes: #{routes[:total_routes]}" if routes.is_a?(Hash) && !routes[:error]
|
|
53
|
+
lines << "- Routes: #{routes[:total_routes]}#{RouteCoverage.suffix(routes)}" if routes.is_a?(Hash) && !routes[:error]
|
|
54
54
|
|
|
55
55
|
lines.concat(full_preset_stack_lines)
|
|
56
56
|
|
|
@@ -46,7 +46,7 @@ module RailsAiContext
|
|
|
46
46
|
app_routes = app_ctrls.sum { |k| Tools::BaseTool.dedupe_put_patch_routes(Array(by_controller[k])).size }
|
|
47
47
|
lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
|
|
48
48
|
"#{count_phrase(app_ctrls.size, "controller")} " \
|
|
49
|
-
"(#{routes[:total_routes]} total incl. framework)"
|
|
49
|
+
"(#{routes[:total_routes]} total incl. framework#{RouteCoverage.suffix(routes)})"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
jobs = context[:jobs]
|
|
@@ -52,7 +52,7 @@ module RailsAiContext
|
|
|
52
52
|
lines << "- Models: #{models.size}" if models.is_a?(Hash) && !models[:error]
|
|
53
53
|
|
|
54
54
|
routes = context[:routes]
|
|
55
|
-
lines << "- Routes: #{routes[:total_routes]}" if routes.is_a?(Hash) && !routes[:error]
|
|
55
|
+
lines << "- Routes: #{routes[:total_routes]}#{RouteCoverage.suffix(routes)}" if routes.is_a?(Hash) && !routes[:error]
|
|
56
56
|
|
|
57
57
|
gems = context[:gems]
|
|
58
58
|
if gems.is_a?(Hash) && !gems[:error]
|
|
@@ -47,7 +47,7 @@ module RailsAiContext
|
|
|
47
47
|
app_routes = app_ctrls.sum { |k| Tools::BaseTool.dedupe_put_patch_routes(Array(by_controller[k])).size }
|
|
48
48
|
lines << "- Routes: #{count_phrase(app_routes, "app route")} across " \
|
|
49
49
|
"#{count_phrase(app_ctrls.size, "controller")} " \
|
|
50
|
-
"(#{routes[:total_routes]} total incl. framework)"
|
|
50
|
+
"(#{routes[:total_routes]} total incl. framework#{RouteCoverage.suffix(routes)})"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
lines.concat(full_preset_stack_lines)
|
|
@@ -117,7 +117,7 @@ module RailsAiContext
|
|
|
117
117
|
routes = context[:routes]
|
|
118
118
|
return if routes[:error]
|
|
119
119
|
|
|
120
|
-
lines = [ "## Routes (#{routes[:total_routes]} total)" ]
|
|
120
|
+
lines = [ "## Routes (#{routes[:total_routes]} total#{RouteCoverage.suffix(routes)})" ]
|
|
121
121
|
routes[:by_controller]&.sort&.each do |ctrl, actions|
|
|
122
122
|
lines << "### #{escape_markdown(ctrl)}"
|
|
123
123
|
actions.each do |r|
|
|
@@ -214,14 +214,41 @@ module RailsAiContext
|
|
|
214
214
|
$stderr.puts tool_banner(server)
|
|
215
215
|
maybe_start_live_reload(server)
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
handler = default_rack_handler
|
|
218
|
+
handler.run(rack_app, **rack_handler_options(handler, config))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def default_rack_handler
|
|
222
|
+
require "rackup"
|
|
223
|
+
Rackup::Handler.default
|
|
224
|
+
rescue LoadError
|
|
225
|
+
# Fallback for older rack without rackup gem
|
|
226
|
+
require "rack/handler"
|
|
227
|
+
Rack::Handler.default
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# MCP sessions live in this process's memory, so a forked worker cannot
|
|
231
|
+
# answer a request whose `initialize` another worker handled - about half
|
|
232
|
+
# of them come back "Session not found". Puma's handler otherwise reads the
|
|
233
|
+
# host app's config/puma.rb, which on a real app sets `workers` (and a
|
|
234
|
+
# pidfile this server would then write over). Both options are load-bearing:
|
|
235
|
+
# refusing the file still leaves WEB_CONCURRENCY able to start a cluster on
|
|
236
|
+
# its own, and pinning the worker count still lets the file's pidfile and
|
|
237
|
+
# preload_app! through.
|
|
238
|
+
def rack_handler_options(handler, config)
|
|
239
|
+
options = { Host: config.http_bind, Port: config.http_port }
|
|
240
|
+
return options unless puma_handler?(handler)
|
|
241
|
+
|
|
242
|
+
$stderr.puts "[rails-ai-context] Puma pinned to single mode - MCP sessions are per-process, " \
|
|
243
|
+
"so config/puma.rb and WEB_CONCURRENCY are not read."
|
|
244
|
+
options.merge(workers: 0, config_files: [ "-" ])
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The handler is whatever Rackup picked, and only Puma understands these
|
|
248
|
+
# keys. Puma::RackHandler is the module both Rackup::Handler::Puma and the
|
|
249
|
+
# older Rack::Handler::Puma extend, so this recognizes either.
|
|
250
|
+
def puma_handler?(handler)
|
|
251
|
+
defined?(::Puma::RackHandler) && handler.singleton_class.include?(::Puma::RackHandler)
|
|
225
252
|
end
|
|
226
253
|
|
|
227
254
|
# Conditionally start live reload based on configuration.
|
|
@@ -448,7 +448,7 @@ namespace :ai do
|
|
|
448
448
|
end
|
|
449
449
|
|
|
450
450
|
if context[:routes] && !context[:routes][:error]
|
|
451
|
-
puts "🛤️ Routes: #{context[:routes][:total_routes]}"
|
|
451
|
+
puts "🛤️ Routes: #{context[:routes][:total_routes]}#{RailsAiContext::RouteCoverage.suffix(context[:routes])}"
|
|
452
452
|
end
|
|
453
453
|
|
|
454
454
|
if context[:jobs]
|
|
@@ -55,9 +55,11 @@ module RailsAiContext
|
|
|
55
55
|
channels = channels.reject { |c| c.is_a?(Hash) && c[:error] }
|
|
56
56
|
channels_note = unavailable_note(jobs_data)
|
|
57
57
|
|
|
58
|
+
sidekiq_line = sidekiq_queues_line(jobs_data)
|
|
59
|
+
|
|
58
60
|
# Single-job query: requires jobs to be present.
|
|
59
61
|
if job
|
|
60
|
-
return text_response(no_job_files_message(jobs_dir_exists)) if job_files.empty?
|
|
62
|
+
return text_response(no_job_files_message(jobs_dir_exists, sidekiq_line)) if job_files.empty?
|
|
61
63
|
return format_single_job(job, job_files, real_jobs_dir, real_root)
|
|
62
64
|
end
|
|
63
65
|
|
|
@@ -67,7 +69,7 @@ module RailsAiContext
|
|
|
67
69
|
# actually ran - if it's unavailable (static tier), say so instead of
|
|
68
70
|
# claiming "no channels detected".
|
|
69
71
|
if job_files.empty? && channels.empty?
|
|
70
|
-
return text_response(no_jobs_or_channels_message(jobs_dir_exists, channels_note))
|
|
72
|
+
return text_response(no_jobs_or_channels_message(jobs_dir_exists, channels_note, sidekiq_line))
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
# Compose: jobs section (if any) + channels section (if any).
|
|
@@ -77,6 +79,13 @@ module RailsAiContext
|
|
|
77
79
|
lines << "" if lines.any?
|
|
78
80
|
lines.concat(format_channels_section(channels))
|
|
79
81
|
end
|
|
82
|
+
# A count of what app/jobs/ holds is still a claim about the app's async
|
|
83
|
+
# work, and on an app that runs most of it through Sidekiq workers that
|
|
84
|
+
# count is the small half. The queues are already in hand either way.
|
|
85
|
+
if sidekiq_line
|
|
86
|
+
lines << "" if lines.any?
|
|
87
|
+
lines << "_#{sidekiq_line} Workers outside app/jobs/ are not covered by this tool._"
|
|
88
|
+
end
|
|
80
89
|
text_response(lines.join("\n"))
|
|
81
90
|
end
|
|
82
91
|
|
|
@@ -84,12 +93,13 @@ module RailsAiContext
|
|
|
84
93
|
# but has no job classes besides ApplicationJob" - both used to be
|
|
85
94
|
# reported as "No app/jobs/ directory found," which is false when the
|
|
86
95
|
# directory is there (e.g. freshly generated by `rails new`).
|
|
87
|
-
private_class_method def self.no_job_files_message(jobs_dir_exists)
|
|
88
|
-
if jobs_dir_exists
|
|
96
|
+
private_class_method def self.no_job_files_message(jobs_dir_exists, sidekiq_line = nil)
|
|
97
|
+
message = if jobs_dir_exists
|
|
89
98
|
"No job classes in app/jobs/ beyond ApplicationJob. Workers outside that directory (Sidekiq::Worker in app/workers/, for example) are not covered by this tool."
|
|
90
99
|
else
|
|
91
100
|
"No app/jobs/ directory. Workers outside it (Sidekiq::Worker in app/workers/, for example) are not covered by this tool."
|
|
92
101
|
end
|
|
102
|
+
[ message, sidekiq_line ].compact.join(" ")
|
|
93
103
|
end
|
|
94
104
|
|
|
95
105
|
# Same distinction for the "nothing async at all" bail-out message,
|
|
@@ -98,13 +108,29 @@ module RailsAiContext
|
|
|
98
108
|
# section never ran (static tier) - in that case, asserting "no Action
|
|
99
109
|
# Cable channels detected" would be a fabricated negative rather than
|
|
100
110
|
# an observed one, so the note replaces that claim instead of joining it.
|
|
101
|
-
private_class_method def self.no_jobs_or_channels_message(jobs_dir_exists, channels_note)
|
|
111
|
+
private_class_method def self.no_jobs_or_channels_message(jobs_dir_exists, channels_note, sidekiq_line = nil)
|
|
102
112
|
channels_clause = channels_note || "no Action Cable channels detected"
|
|
103
|
-
if jobs_dir_exists
|
|
113
|
+
message = if jobs_dir_exists
|
|
104
114
|
"No job classes in app/jobs/ beyond ApplicationJob, and #{channels_clause}. Workers outside that directory are not covered by this tool."
|
|
105
115
|
else
|
|
106
116
|
"No app/jobs/ directory and #{channels_clause}. Workers outside it are not covered by this tool."
|
|
107
117
|
end
|
|
118
|
+
[ message, sidekiq_line ].compact.join(" ")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# JobIntrospector#extract_sidekiq_config already read this file, and on an
|
|
122
|
+
# app that runs everything through Sidekiq workers it is the only evidence
|
|
123
|
+
# in reach that async work happens at all.
|
|
124
|
+
private_class_method def self.sidekiq_queues_line(jobs_data)
|
|
125
|
+
config = jobs_data.is_a?(Hash) ? jobs_data[:sidekiq_config] : nil
|
|
126
|
+
return nil unless config.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
queues = Array(config[:queues])
|
|
129
|
+
return nil if queues.empty?
|
|
130
|
+
|
|
131
|
+
line = "config/sidekiq.yml declares #{count_phrase(queues.size, "queue")}: #{queues.join(', ')}"
|
|
132
|
+
line += " (concurrency: #{config[:concurrency]})" if config[:concurrency]
|
|
133
|
+
"#{line}."
|
|
108
134
|
end
|
|
109
135
|
|
|
110
136
|
private_class_method def self.format_single_job(job, job_files, jobs_dir, root)
|
|
@@ -89,6 +89,12 @@ module RailsAiContext
|
|
|
89
89
|
if unattributed_count > 0 && controller.nil?
|
|
90
90
|
count_label += " and #{count_phrase(unattributed_count, "engine mount")}"
|
|
91
91
|
end
|
|
92
|
+
# Dropping the count of what the static tier could not expand let a
|
|
93
|
+
# partial list read as the whole routing table, which is the one
|
|
94
|
+
# thing an unbooted answer must not do. All three detail levels share
|
|
95
|
+
# this label. Gated on `controller` because the caveat is about the
|
|
96
|
+
# whole table, and a filtered answer is not that.
|
|
97
|
+
count_label += RailsAiContext::RouteCoverage.suffix(routes) if controller.nil?
|
|
92
98
|
|
|
93
99
|
case detail
|
|
94
100
|
when "summary"
|
|
@@ -285,7 +285,9 @@ module RailsAiContext
|
|
|
285
285
|
.select { |k, _| prefixes.any? { |p| k.downcase.start_with?(p) } }
|
|
286
286
|
.values.sum { |route_list| dedupe_put_patch_routes(route_list).size }
|
|
287
287
|
framework_note = framework_count > 0 ? " (plus #{count_phrase(framework_count, 'framework route')})" : ""
|
|
288
|
-
lines << "Total: #{count_phrase(app_route_count, 'app route')} across
|
|
288
|
+
lines << "Total: #{count_phrase(app_route_count, 'app route')} across " \
|
|
289
|
+
"#{count_phrase(app_ctrls.size, 'controller')}#{framework_note}" \
|
|
290
|
+
"#{RailsAiContext::RouteCoverage.suffix(routes)}."
|
|
289
291
|
lines << ""
|
|
290
292
|
lines
|
|
291
293
|
end
|
data/server.json
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-ai-context
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.20.
|
|
4
|
+
version: 5.20.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crisnahine
|
|
@@ -385,6 +385,7 @@ files:
|
|
|
385
385
|
- lib/rails_ai_context/presets.rb
|
|
386
386
|
- lib/rails_ai_context/redaction.rb
|
|
387
387
|
- lib/rails_ai_context/resources.rb
|
|
388
|
+
- lib/rails_ai_context/route_coverage.rb
|
|
388
389
|
- lib/rails_ai_context/safe_file.rb
|
|
389
390
|
- lib/rails_ai_context/schema_adapter.rb
|
|
390
391
|
- lib/rails_ai_context/schema_hint.rb
|