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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81eedfa398603e64a27f7555b4288e39a11e41093c3a06450354c2f5cb1d17c5
4
- data.tar.gz: 34972bf4259e8e12695f008744111bff94ed7caa93155bd3f8352010e3134c15
3
+ metadata.gz: 812fc2b5160e39c746e9d399bf6cea5752339f53c57a80f3ce0d01a5e819b7d0
4
+ data.tar.gz: 22254f62db474a0f0b7f364f156b3098819b52285e88df2835b32ea25c86972b
5
5
  SHA512:
6
- metadata.gz: c88339dc6c73a5fdb38209d69e61437a6494382c069ae943478e51a265e57b2b847d4cb89f22ef0449c024bdb43185feef7e58a5e92f199c0a0389f4822898ca
7
- data.tar.gz: 2f0a283b5691f00d4f49aeed839da56af13e248f566c9a526e3926cbc0c71185cf2e99a9b809d5e316d7e1144826456bb65ea36148493b4102f44b3bf0857584
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
@@ -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 background jobs (ActiveJob/Sidekiq), mailers,
6
- # and Action Cable channels.
5
+ # Discovers ActiveJob jobs, mailers, and Action Cable channels. Sidekiq
6
+ # reaches this only as config/sidekiq.yml: a class that includes
7
+ # Sidekiq::Worker without subclassing ActiveJob::Base is not a descendant
8
+ # and does not live in app/jobs/, so neither pass here finds it.
7
9
  class JobIntrospector
8
10
  extend StaticTier
9
11
  static_tier :alternate_source
@@ -52,6 +54,7 @@ module RailsAiContext
52
54
  ActiveJob::Base.descendants.filter_map do |job|
53
55
  next if job.name.nil? || job.name == "ApplicationJob" ||
54
56
  job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
57
+ next unless app_defined?(job)
55
58
 
56
59
  queue = job.queue_name
57
60
  queue = "dynamic" if queue.is_a?(Proc)
@@ -67,6 +70,26 @@ module RailsAiContext
67
70
  []
68
71
  end
69
72
 
73
+ # `descendants` is every ActiveJob subclass in the process, and the name
74
+ # prefixes above only cover the framework's own - a job from any other gem
75
+ # was counted as the app's. Where the class is defined answers it for gems
76
+ # the list has never heard of. A class with no source location stays:
77
+ # dropping one would understate what the app runs.
78
+ def app_defined?(job)
79
+ location = Object.const_source_location(job.name)&.first
80
+ return true unless location
81
+
82
+ File.realpath(location).start_with?("#{app_root_real}/")
83
+ rescue NameError, ArgumentError, TypeError, SystemCallError
84
+ true
85
+ end
86
+
87
+ def app_root_real
88
+ @app_root_real ||= File.realpath(app.root.to_s)
89
+ rescue SystemCallError
90
+ @app_root_real = app.root.to_s
91
+ end
92
+
70
93
  def extract_jobs_from_source
71
94
  jobs_dir = File.join(app.root, "app", "jobs")
72
95
  return [] unless Dir.exist?(jobs_dir)
@@ -255,10 +278,11 @@ module RailsAiContext
255
278
  []
256
279
  end
257
280
 
258
- # A mailer's actions are its public instance methods. The booted side asks
259
- # `action_methods`, which also subtracts inherited and internal ones - the
260
- # AST cannot see that, so a public helper on a concrete mailer is still
261
- # listed here and not there.
281
+ # A mailer's actions are its public instance methods, and the AST sees
282
+ # one file at a time. `action_methods` counts the public methods a mailer
283
+ # inherits too, so a public helper on a base class is an action the
284
+ # booted tier reports and this one cannot - the entries are tagged STATIC
285
+ # for that reason.
262
286
  def extract_mailers_from_source
263
287
  source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
264
288
  next if name == "ApplicationMailer"
@@ -272,7 +272,17 @@ module RailsAiContext
272
272
  def emit_dynamic(node)
273
273
  return if suppressed?
274
274
 
275
- @results << { type: :dynamic, macro: node.name, location: node.location.start_line }
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 alone.
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
- ast = SourceIntrospector.walk(routes_path, {
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
- dynamic = records.count { |r| r[:type] == :dynamic }
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
- total_routes: entries.size,
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: (ast[:mounts] || []).map { |m| { engine: m[:engine], path: m[:path] } },
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 config/routes.rb (app not booted)",
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)
@@ -82,7 +82,7 @@ module RailsAiContext
82
82
 
83
83
  routes = context[:routes]
84
84
  if routes && !routes[:error]
85
- lines << "- Routes: #{routes[:total_routes]}"
85
+ lines << "- Routes: #{routes[:total_routes]}#{RouteCoverage.suffix(routes)}"
86
86
  end
87
87
 
88
88
  gems = context[:gems]
@@ -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
- begin
218
- require "rackup"
219
- Rackup::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
220
- rescue LoadError
221
- # Fallback for older rack without rackup gem
222
- require "rack/handler"
223
- Rack::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
224
- end
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 #{count_phrase(app_ctrls.size, 'controller')}#{framework_note}."
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.20.2"
4
+ VERSION = "5.20.3"
5
5
  end
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.20.1",
10
+ "version": "5.20.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
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.2
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