rails-ai-context 5.36.2 → 5.36.4

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: 0a04632cfd1114032ce4434e7e8b99d5f92c81bc9537bf5c65b6c65a3124c278
4
- data.tar.gz: 3c5210bcb7ba0fef976a00b1224647588d44556bb8d807cbbc87aca9ff0214ce
3
+ metadata.gz: d0dd31bd07cbfa7f5cb1e1232627cda1c7ea16f035f864d8db86e1f544bb3893
4
+ data.tar.gz: 92512c007d7fc751a59bf72b1f651fb2a6e58117bc47697e109c145cbd65b6cf
5
5
  SHA512:
6
- metadata.gz: 41e3246dc1905379a9d39bc4bfb8e95498b752a95f1a96b2e955ad58ca22a06696d0c12fd1b676e7414086cea5e571f772c8638dec36823ee542462dca8f4e64
7
- data.tar.gz: ea9ebf310554c650503a9f9b95521988eccbf42286885b294ae86031d8c393a3dc71161ecab3852bb69a17055167b5334669fd0187817c62c73f98ecbf0843c1
6
+ metadata.gz: 0d751847ce7745680c03f69a54077e1f8a1836c37e788d78ad5072792af68be4f7e531b98e5bc3b62159e8b297259d8ebc2649ee22c41b6150edc90e9faf0c69
7
+ data.tar.gz: a64566fdec0abb014195d48dcf81d2463a24b9c72addaec1254dcac0778e6f4190805baddfdf073ec2ca5b847fe4ebc4ba643de759b46fc5a16e13b586e80dab
data/CHANGELOG.md CHANGED
@@ -5,6 +5,80 @@ 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.36.4] - 2026-10-16
9
+
10
+ ### Added
11
+
12
+ - **The standalone binary says when a newer release is out.** A copy installed
13
+ with `gem install` stayed at its version until someone thought to update it.
14
+ It now asks rubygems.org for the latest version at most once a day, in a
15
+ detached child process, and keeps the answer under `$XDG_STATE_HOME`
16
+ (`~/.local/state/rails-ai-context/latest`). A command run in a terminal prints
17
+ one line on stderr naming the Ruby to update under and the two commands to
18
+ run. The MCP server adds the line to its instructions and to the end of
19
+ `rails_onboard` for the agent to pass on, or asks for a restart when the new
20
+ version is already installed. `RAILS_AI_CONTEXT_NO_UPDATE_CHECK=1` turns it
21
+ off. It is off in CI, on a prerelease, for an older version run on purpose
22
+ (`_5.30.0_`) and in a copy the app's bundle runs. Nothing is installed for you.
23
+
24
+ ### Fixed
25
+
26
+ - **The code reads prism 2 the way it reads prism 1.** prism's main branch
27
+ (2.0.0.rc1) parses with the running Ruby's grammar when it is given none,
28
+ replaced `MissingNode` with `ErrorRecoveryNode`, and leaves an unterminated
29
+ heredoc without a closing location. The gem now names the newest grammar
30
+ when it parses and lexes, reads either recovery node, and ends an
31
+ unterminated heredoc at its body. The gemspec still requires prism below 2.0.
32
+
33
+ ## [5.36.3] - 2026-10-15
34
+
35
+ ### Fixed
36
+
37
+ - **`rails_diagnose` names an instance variable receiver with its `@`, and takes it from the line the error names.**
38
+ `@widget.name` read as `widget`, so the advice named a variable the action does
39
+ not have. The receiver also came from the first call of the method in the file,
40
+ not from the line. A line that does not call the method on a plain name now
41
+ gets no receiver instead of another line's. 5.36.2 and earlier versions had the
42
+ gap.
43
+ - **`rails_generate_test` says which sign-in helper names it looked for.** The
44
+ TODO read "this app's tests define no sign-in helper" for a suite that signs in
45
+ through a helper with another name. It now reads "no `sign_in` or `log_in`
46
+ helper in this app's test helpers".
47
+ - **A `change_table` migration lists what its block does.** `rails://migrations`
48
+ showed `"actions": []` for a migration that adds columns through
49
+ `change_table`, and `migration_stats` left it out of `total_add_column`. The
50
+ table calls in the block (`t.string`, `t.references`, `t.index`, `t.remove` and
51
+ the others) are read from the AST. A `create_table` block's columns are not
52
+ counted.
53
+ - **`rails_search_code` trace says how many sibling methods and internal calls it
54
+ left out.** It listed 10 siblings and 15 internal calls and stopped. The list
55
+ now ends with "_...and N more_".
56
+ - **`rails_get_job_pattern` reads sidekiq-scheduler entries from every
57
+ `config/sidekiq*.yml`.** A schedule kept in `config/sidekiq_production.yml`,
58
+ one file per environment, showed no `Schedule`, so a worker that runs every
59
+ night read like dead code. Each file's entry is shown with its file name.
60
+ - **`rails_get_config(section:"conventions")` counts the rake tasks of
61
+ `lib/tasks` and the templates of `app/views`.** The directory counts took
62
+ `.rb` files only: `lib/tasks` read 2 files for 67, and `app/views` never
63
+ appeared.
64
+ - **`rails_get_test_info` counts only `*_spec.rb` under `spec/` in an RSpec
65
+ suite.** A `*_test.rb` helper in `spec/support` was counted as a test file. A
66
+ Minitest suite still counts both `*_spec.rb` and `*_test.rb`.
67
+ - **`rails_migration_advisor` writes a reference and a new table with the key
68
+ type the app uses.** On a schema of `id: :uuid` tables, `add_reference` made a
69
+ bigint column that PostgreSQL refuses to key to a uuid, and `create_table`
70
+ made the one bigint table. `add_reference` and `t.references` take the key type
71
+ of the table they point at (the app's type when the schema lacks that table),
72
+ and `create_table` takes the type most of the app's tables share.
73
+ - **`rails_review_changes` matches `files` by whole path or directory.**
74
+ `files: ["Gemfile"]` also returned `Gemfile.lock`. A directory such as
75
+ `app/models` still matches the files under it.
76
+ - **`rails_analyze_feature` says which directories it stopped reading.** It reads
77
+ the first 500 files of a directory, and a service, view or test past that was
78
+ missed with no word. The answer now ends with the directories it did not
79
+ finish. The "first 500 scanned" note no longer shows when exactly 500 files were
80
+ read.
81
+
8
82
  ## [5.36.2] - 2026-10-15
9
83
 
10
84
  ### Fixed
data/SECURITY.md CHANGED
@@ -32,4 +32,4 @@ If you discover a security vulnerability in rails-ai-context, please report it r
32
32
  - **Migration input validation** - `rails_migration_advisor` validates table and column names as safe identifiers before generating migration code.
33
33
  - **Cache invalidation coverage** - Fingerprinter watches `app/components`, `package.json`, and `tsconfig.json` alongside models/controllers/views to prevent stale tool responses.
34
34
  - **Fetch size limits** - `rails_search_docs` caps fetched documentation content at 2MB to prevent memory exhaustion.
35
- - The gem makes outbound HTTPS requests only when `rails_search_docs` is called with `fetch: true` (to fetch Rails documentation from GitHub raw content). All other tools are offline.
35
+ - The gem makes outbound HTTPS requests in two places. `rails_search_docs` fetches Rails documentation from GitHub raw content when called with `fetch: true`. The standalone binary (installed with `gem install`, not through the app's Gemfile) asks `https://rubygems.org/api/v1/versions/rails-ai-context/latest.json` for the latest version at most once a day, in a child process that sends nothing about the app, and writes the answer to `rails-ai-context/latest` under `$XDG_STATE_HOME` (`~/.local/state`). `RAILS_AI_CONTEXT_NO_UPDATE_CHECK=1` turns it off; it is off in CI and in a copy the app's bundle runs. All other tools are offline.
@@ -333,6 +333,19 @@ Proof sources:
333
333
  two; MariaDB 10.6 and 11.4 through mysql2 built against MariaDB's client
334
334
  library on Linux; `rails_runtime_info` against a real Sidekiq and Redis; and
335
335
  the unit suite and the E2E harness on Linux, macOS and Windows.
336
+ In the v5.36.3 release QA: each fix's example failing on the v5.36.2 code;
337
+ the issues' repros run through the CLI on a minimal app, and on copies of two
338
+ large apps (both in the static tier, one also booted) against counts taken
339
+ from their files; the introspector, tool and integration specs on Ruby 3.1
340
+ with Rails 7.0 and 7.2, Ruby 3.3 with Rails 7.1, Ruby 3.4 with Rails 8.0 and
341
+ 8.1, and Ruby 4.0 with Rails 8.1; and the unit suite and the E2E harness on
342
+ Linux, macOS and Windows.
343
+ In the v5.36.4 release QA: the update check run as the installed binary on
344
+ Ruby 3.1, 3.3, 3.4 and 4.0 against rubygems.org, through a terminal and over
345
+ MCP stdio on a fixture app, a large private app booted and another in the
346
+ static tier, with the app's pinned json and net-http still loaded; the
347
+ suite on prism 1.4.0 and on prism main under Ruby 3.2 and 3.4; and the unit
348
+ suite and the E2E harness on Linux, macOS and Windows.
336
349
  2. Non-crash coverage for every built-in tool including `get_view` in
337
350
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
338
351
  (ivar cross-check, render-form detection, partial interfaces) verified
@@ -266,6 +266,7 @@ server entry's `env`.
266
266
  | Variable | Read by | Effect |
267
267
  |:---------|:--------|:-------|
268
268
  | `RAILS_AI_CONTEXT_BOOT_TIMEOUT` | Every command that boots the app, and the `ai:serve`, `ai:serve_http` and `ai:tool` rake tasks | Seconds a boot may take before it counts as failed; a command that can then answers from the static tier. Default 20 for `serve` over stdio and `rails ai:serve`, whose client waits about 30 seconds for its first answer, and 60 for everything else. A value that is not a whole number warns and keeps the default |
269
+ | `RAILS_AI_CONTEXT_NO_UPDATE_CHECK` | The standalone binary | Any non-empty value turns off the daily check for a newer release on rubygems.org. The check also stays off in CI, on a prerelease and in a copy the app's bundle runs |
269
270
  | `RAILS_AI_CONTEXT_SERVER_NAME` | The MCP server | The name it announces for one process, in place of the default `server_name`; a `server_name` the app sets wins. A workspace's MCP entries set it |
270
271
  | `DEBUG` | Every command and the MCP server | Any value prints the backtrace of a failed boot, a failed command and an exception that escapes a tool, and names the errors a fallback swallows |
271
272
  | `RAILS_ENV`, `RACK_ENV` | Every command | The Rails environment to boot, read the way Rails reads it; `--environment` sets it for one command. Default `development` |
@@ -285,7 +285,7 @@ same wherever it is asked. Those live as their own modules under
285
285
  | `ModuleAliases` | Which app file a bare JS import specifier names: tsconfig/jsconfig `compilerOptions.paths` followed through `extends` (relative files and installed packages), and a vite/webpack/rspack `resolve.alias` written as a literal object. The Stimulus scan uses it to tie a registration or a base class to the controller file it imports |
286
286
  | `HelperNames` | The helper methods a view can call: every method the app's helper modules define in every code root, and those of a module they `include`, found through the app's autoload roots (`lib` among them) or in its enclosing namespace's file (`CanonicalURL::Helpers` in `canonical_url.rb`). `rails_get_partial_interface` uses it so a helper call is not read as a local |
287
287
  | `Interaction` | Whether a class runs as an ActiveInteraction, following its superclass chain through the app's own sources, and the filters it takes - inherited ones first, one per name, each carrying the filters nested inside its block. See the **Interaction filter** entry in `CONTEXT.md` |
288
- | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of `config/sidekiq.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
288
+ | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of each `config/sidekiq*.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
289
289
  | `AdminResources` | The models an admin gem exposes (ActiveAdmin, Trestle, Administrate, Avo, Madmin), from the folder each gem's generator writes to, with ActiveAdmin's `permit_params`. `rails_get_config(section:"conventions")` and `rails_analyze_feature` both ask it |
290
290
  | `SchemaDumpPath` | The primary database's dump file as `DatabaseTasks.schema_dump_path` names it: `database.yml`'s `schema_dump` in the configured `schema_format`, then each format's default file, and the secondary databases' dumps. Every schema reader, the doctor and the pending-migration check ask it |
291
291
  | `ApartmentConfig` | The models an Apartment initializer keeps in the shared schema (`excluded_models`), or the source of a list the file computes. The model introspector asks it to say which models get one table per tenant |
data/docs/SECURITY.md CHANGED
@@ -18,7 +18,7 @@
18
18
  1. **Read-only by design** - All 35 tools are annotated as non-destructive in the MCP protocol
19
19
  2. **Defense in depth** - Multiple security layers, not single points of failure
20
20
  3. **Sensitive data blocking** - Configurable patterns prevent access to secrets
21
- 4. **Offline by default** - No network calls except optional `rails_search_docs` with `fetch: true`
21
+ 4. **Offline except where named** - No network calls except optional `rails_search_docs` with `fetch: true`, and the standalone binary's daily version check against rubygems.org (`RAILS_AI_CONTEXT_NO_UPDATE_CHECK=1` turns it off)
22
22
  5. **Graceful degradation** - Missing optional dependencies don't expose errors or state
23
23
 
24
24
  ---
data/docs/STANDALONE.md CHANGED
@@ -140,6 +140,26 @@ Codex CLI is special - it `env_clear()`s the process before spawning MCP servers
140
140
 
141
141
  If you switch Ruby versions, re-run `rails-ai-context init` to update the snapshot.
142
142
 
143
+ ## Updating
144
+
145
+ A gem installed with `gem install` stays at its version until you update it:
146
+
147
+ ```bash
148
+ gem update rails-ai-context
149
+ rails-ai-context init --defaults # in each app: brings its MCP configs and context files up to the new version
150
+ ```
151
+
152
+ Each Ruby has its own copy, so update under the Ruby your AI client starts the
153
+ server with. Restart the AI client afterwards so its MCP server runs the new copy.
154
+
155
+ The binary checks rubygems.org for a newer release at most once a day, in the
156
+ background. When there is one, a command run in a terminal says so on stderr,
157
+ and the MCP server tells the agent to pass it on, in its instructions and at the
158
+ end of `rails_onboard`. A server reads the answer the check left the last time,
159
+ so the first check shows up from the next start. `RAILS_AI_CONTEXT_NO_UPDATE_CHECK=1` turns the check off.
160
+ It stays off in CI and in a copy the app's bundle runs, where the lockfile picks
161
+ the version.
162
+
143
163
  ## Switching between standalone and in-Gemfile
144
164
 
145
165
  You can switch freely:
data/docs/TOOLS.md CHANGED
@@ -826,7 +826,7 @@ Each diff passes through the redaction every tool's source does, read as the fil
826
826
  | Parameter | Type | Default | Description |
827
827
  |:----------|:-----|:--------|:------------|
828
828
  | `ref` | string | `HEAD` | Git ref to diff against: a branch, a tag, `HEAD~3` or a commit SHA. `HEAD` reviews uncommitted changes. Any other ref reviews the commits since HEAD left it, as a pull request does: the file list and each file's diff run from the commit the two last shared to HEAD. A ref that names no commit is refused |
829
- | `files` | array | - | Review only these changed files (omit for all) |
829
+ | `files` | array | - | Review only these changed files, or the changed files under these directories (omit for all). A path matches whole, so `Gemfile` is not `Gemfile.lock` |
830
830
 
831
831
  ### `rails_runtime_info`
832
832
 
data/exe/rails-ai-context CHANGED
@@ -201,7 +201,10 @@ class RailsAiContextCLI < Thor
201
201
  # One answer for any command's failure; Thor's refusals and SystemExit pass through.
202
202
  no_commands do
203
203
  def invoke_command(command, *args)
204
- super
204
+ start_update_check
205
+ result = super
206
+ check_for_update
207
+ result
205
208
  rescue Thor::Error
206
209
  raise
207
210
  rescue StandardError, ScriptError => e
@@ -319,6 +322,7 @@ class RailsAiContextCLI < Thor
319
322
  # No Thor default: it would overwrite the config file's http_port.
320
323
  ::RailsAiContext.configuration.http_port = options[:port] if options[:port]
321
324
 
325
+ start_update_check&.refresh(env: STARTING_ENV)
322
326
  ::RailsAiContext.start_mcp_server(transport: transport)
323
327
  end
324
328
 
@@ -1326,6 +1330,29 @@ class RailsAiContextCLI < Thor
1326
1330
  nil
1327
1331
  end
1328
1332
 
1333
+ # A notice on a terminal only: stdout may be piped, and an agent reads rails_onboard's copy instead.
1334
+ def check_for_update
1335
+ check = start_update_check or return
1336
+ check.refresh(env: STARTING_ENV)
1337
+ note = check.notice unless check.told?
1338
+ $stderr.puts note if note && $stderr.tty?
1339
+ rescue StandardError, ScriptError
1340
+ nil
1341
+ end
1342
+
1343
+ # Only this copy run outside a bundle checks; under bundle exec the app's lockfile picks the version.
1344
+ # Started before the command, while Gem.path is still the one an app's boot replaces.
1345
+ def start_update_check
1346
+ return if STARTING_ENV["BUNDLE_BIN_PATH"]
1347
+
1348
+ @start_update_check ||= begin
1349
+ require_relative "../lib/rails_ai_context/update_check"
1350
+ ::RailsAiContext::UpdateCheck.tap(&:enable!)
1351
+ end
1352
+ rescue StandardError, ScriptError
1353
+ nil
1354
+ end
1355
+
1329
1356
  # Stdlib only, so it is safe to load before the app boots.
1330
1357
  def entry_boot
1331
1358
  @entry_boot ||= begin
@@ -80,14 +80,14 @@ module RailsAiContext
80
80
  # A one-shot parse that stores nothing: for a scan reading thousands of
81
81
  # files once, whose entries would only evict what other readers reuse.
82
82
  def self.parse_uncached(source)
83
- Prism.parse(source)
83
+ Prism.parse(source, version: NEWEST_GRAMMAR)
84
84
  end
85
85
 
86
86
  # Whether a file parses under `ruby:`'s grammar, with no tree built or
87
87
  # kept: the check before a loader requires each file it walks.
88
88
  def self.parse_success?(path, ruby: nil)
89
89
  version = prism_version(ruby)
90
- version ? Prism.parse_file_success?(path, version: version) : Prism.parse_file_success?(path)
90
+ Prism.parse_file_success?(path, version: version || NEWEST_GRAMMAR)
91
91
  end
92
92
 
93
93
  # Parse a Ruby source string, cached by content digest, so every extractor
@@ -112,6 +112,8 @@ module RailsAiContext
112
112
  private_class_method :content_key
113
113
 
114
114
  OLDEST_GRAMMAR = "3.3"
115
+ # Named, because prism 2 parses with the running Ruby's grammar when given none.
116
+ NEWEST_GRAMMAR = "latest"
115
117
  GRAMMARS = Concurrent::Map.new # major.minor => whether the loaded prism parses it
116
118
 
117
119
  # The grammar prism parses `ruby` with: its own major.minor, the oldest prism
@@ -134,7 +136,7 @@ module RailsAiContext
134
136
  private_class_method :supported?
135
137
 
136
138
  def self.prism_parse(source, version)
137
- version ? Prism.parse(source, version: version) : Prism.parse(source)
139
+ Prism.parse(source, version: version || NEWEST_GRAMMAR)
138
140
  end
139
141
  private_class_method :prism_parse
140
142
 
@@ -172,6 +172,14 @@ module RailsAiContext
172
172
  RailsAiContext.debug_fail(e, false, label: "uses_async_queries?")
173
173
  end
174
174
 
175
+ # Files a directory holds besides Ruby, which SourceScan leaves out.
176
+ NON_RUBY_FILES = {
177
+ "app/javascript" => "**/*.js",
178
+ "app/javascript/controllers" => "**/*.js",
179
+ "app/views" => "**/*.{erb,haml,slim,jbuilder,builder}",
180
+ "lib/tasks" => "**/*.rake"
181
+ }.freeze
182
+
175
183
  def scan_directory_structure
176
184
  important_dirs = %w[
177
185
  app/models app/controllers app/views app/jobs
@@ -190,7 +198,8 @@ module RailsAiContext
190
198
  # carries file counts, so the biggest code area had no number anywhere.
191
199
  (important_dirs | app_child_dirs).each_with_object({}) do |dir, hash|
192
200
  count = SourceScan.paths(root, kind: dir, skip_concerns: false).count
193
- count += Dir.glob(File.join(root, dir, "**/*.js")).size if dir.include?("javascript")
201
+ glob = NON_RUBY_FILES[dir]
202
+ count += PathResolver.dirs_for(root, dir).sum { |path| Dir.glob(File.join(path, glob)).size } if glob
194
203
 
195
204
  hash[dir] = count if count > 0
196
205
  end
@@ -122,7 +122,7 @@ module RailsAiContext
122
122
  end
123
123
 
124
124
  def lex(text)
125
- Prism.lex(text).value.map(&:first)
125
+ Prism.lex(text, version: AstCache::NEWEST_GRAMMAR).value.map(&:first)
126
126
  rescue StandardError
127
127
  []
128
128
  end
@@ -9,6 +9,9 @@ module RailsAiContext
9
9
  # and Que's `self.queue = "name"`, or a `def self.queue` returning one, which
10
10
  # Resque asks for when @queue is unset. A literal is kept as its value, anything else as source.
11
11
  class QueueAssignmentListener < BaseListener
12
+ # What a half-written `@queue =` parses its value as: MissingNode before prism 2, ErrorRecoveryNode from it.
13
+ RECOVERY_NODES = %i[MissingNode ErrorRecoveryNode].filter_map { |name| Prism.const_get(name) if Prism.const_defined?(name, false) }.freeze
14
+
12
15
  def initialize
13
16
  super
14
17
  @def_depth = 0
@@ -40,9 +43,8 @@ module RailsAiContext
40
43
 
41
44
  private
42
45
 
43
- # A half-written `@queue =` parses with a MissingNode value.
44
46
  def record(node, value, form)
45
- return if value.is_a?(Prism::MissingNode)
47
+ return if RECOVERY_NODES.any? { |type| value.is_a?(type) }
46
48
 
47
49
  literal = value.is_a?(Prism::SymbolNode) || value.is_a?(Prism::StringNode)
48
50
  @results << { form: form, queue: literal ? value.unescaped : nil, source: value.slice,
@@ -121,7 +121,32 @@ module RailsAiContext
121
121
  add_foreign_key remove_foreign_key
122
122
  add_timestamps create_join_table enable_extension execute
123
123
  add_check_constraint remove_check_constraint
124
- ].select { |action| content.include?(action) }
124
+ ].select { |action| content.include?(action) } | change_table_actions(content)
125
+ end
126
+
127
+ # What each table call in a change_table block does; a column type adds a column, any other call does nothing.
128
+ CHANGE_TABLE_CALLS = {
129
+ index: "add_index", remove_index: "remove_index", remove: "remove_column", remove_timestamps: "remove_column",
130
+ remove_references: "remove_reference", remove_belongs_to: "remove_reference", references: "add_reference",
131
+ belongs_to: "add_reference", timestamps: "add_timestamps", rename: "rename_column", change: "change_column",
132
+ change_default: "change_column_default", change_null: "change_column_null", foreign_key: "add_foreign_key",
133
+ remove_foreign_key: "remove_foreign_key", check_constraint: "add_check_constraint",
134
+ remove_check_constraint: "remove_check_constraint"
135
+ }.freeze
136
+ COLUMN_CALLS = (Listeners::SchemaDslListener::COLUMN_TYPES.map(&:to_sym) + [ :column ] - CHANGE_TABLE_CALLS.keys).freeze
137
+
138
+ # A table call counts only inside a change_table block: the listener links each call to the block that encloses it.
139
+ def change_table_actions(content)
140
+ return [] unless content.include?("change_table")
141
+
142
+ hits = SourceIntrospector.walk_source(content, {
143
+ change_table: -> { Listeners::GenericMacroListener.new(:change_table, *CHANGE_TABLE_CALLS.keys, *COLUMN_CALLS, any_receiver: true) }
144
+ })[:change_table]
145
+ blocks = hits.select { |hit| hit[:macro] == :change_table }.to_set { |hit| hit[:offset] }
146
+ return [] if blocks.empty?
147
+
148
+ inside = hits.select { |hit| blocks.include?(hit[:parent_offset]) }
149
+ [ "change_table" ] | inside.map { |hit| CHANGE_TABLE_CALLS.fetch(hit[:macro], "add_column") }
125
150
  end
126
151
  end
127
152
  end
@@ -18,12 +18,12 @@ module RailsAiContext
18
18
  # @return [String] the node's slice, followed by the body of every
19
19
  # heredoc it opens whose body lies past its end
20
20
  def text(node)
21
- trailing = heredocs(node).select { |doc| doc.closing_loc.end_offset > node.location.end_offset }
21
+ trailing = heredocs(node).select { |doc| body_end(doc).end_offset > node.location.end_offset }
22
22
  return node.slice if trailing.empty?
23
23
 
24
24
  first = trailing.min_by { |doc| body_start(doc).start_offset }
25
- last = trailing.max_by { |doc| doc.closing_loc.end_offset }
26
- "#{node.slice}\n#{body_start(first).join(last.closing_loc).slice}"
25
+ last = trailing.max_by { |doc| body_end(doc).end_offset }
26
+ "#{node.slice}\n#{body_start(first).join(body_end(last)).slice}"
27
27
  end
28
28
 
29
29
  def heredocs(node)
@@ -40,9 +40,14 @@ module RailsAiContext
40
40
  def body_start(doc)
41
41
  if doc.respond_to?(:content_loc) then doc.content_loc
42
42
  elsif doc.parts.any? then doc.parts.first.location
43
- else doc.closing_loc
43
+ else body_end(doc)
44
44
  end
45
45
  end
46
+
47
+ # The terminator; an unterminated heredoc has none from prism 2 on, and ends with its body.
48
+ def body_end(doc)
49
+ doc.closing_loc || (doc.content_loc if doc.respond_to?(:content_loc)) || doc.parts.last&.location || doc.location
50
+ end
46
51
  end
47
52
  end
48
53
  end
@@ -26,24 +26,28 @@ module RailsAiContext
26
26
  end
27
27
  end
28
28
 
29
+ SIDEKIQ_CRON_FILES = %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].freeze
30
+
29
31
  def sidekiq_cron(root)
30
- %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].flat_map do |file|
32
+ SIDEKIQ_CRON_FILES.flat_map do |file|
31
33
  named_tasks(yaml(root, file)).filter_map { |name, options| task(file, name, options, :cron) }
32
34
  end
33
35
  end
34
36
 
35
37
  # sidekiq-scheduler runs a job by its entry's name when no class is given.
38
+ # Sidekiq starts from any file `-C` names, and apps keep one per environment.
36
39
  def sidekiq_scheduler(root)
37
- file = "config/sidekiq.yml"
38
- data = yaml(root, file)
39
- return [] unless data.is_a?(Hash)
40
+ (Dir.glob("config/sidekiq*.{yml,yaml}", base: root.to_s).sort - SIDEKIQ_CRON_FILES).flat_map do |file|
41
+ data = yaml(root, file)
42
+ next [] unless data.is_a?(Hash)
40
43
 
41
- section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
42
- named_tasks(section).filter_map do |name, options|
43
- next unless options.is_a?(Hash)
44
+ section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
45
+ named_tasks(section).filter_map do |name, options|
46
+ next unless options.is_a?(Hash)
44
47
 
45
- kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
46
- task(file, name, { "class" => name }.merge(options), kind)
48
+ kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
49
+ task(file, name, { "class" => name }.merge(options), kind)
50
+ end
47
51
  end
48
52
  end
49
53
 
@@ -10,8 +10,6 @@ module RailsAiContext
10
10
  extend StaticTier
11
11
  static_tier :files_only
12
12
 
13
- TEST_FILE_GLOB = "*_{spec,test}.rb"
14
-
15
13
  # An engine's test/dummy is tested by the engine's suite.
16
14
  def suite_root
17
15
  @suite_root ||= PathResolver.test_root(root)
@@ -56,6 +54,17 @@ module RailsAiContext
56
54
  RailsAiContext.debug_fail(e, "unknown", label: "detect_framework")
57
55
  end
58
56
 
57
+ # RSpec runs *_spec.rb only, so a *_test.rb under spec/ is a helper.
58
+ def test_file_glob(base)
59
+ base == "spec" && rspec_suite? ? "*_spec.rb" : "*_{spec,test}.rb"
60
+ end
61
+
62
+ def rspec_suite?
63
+ return @rspec_suite if defined?(@rspec_suite)
64
+
65
+ @rspec_suite = RailsAiContext::TestFramework.suites(suite_root).include?("rspec")
66
+ end
67
+
59
68
  # First listed wins: an app holding the same thing under spec/ and test/
60
69
  # reports one location, not both. `detect_system_tests` deliberately
61
70
  # answers the other way.
@@ -247,7 +256,7 @@ module RailsAiContext
247
256
  dir = File.join(suite_root, rel)
248
257
  next unless Dir.exist?(dir)
249
258
 
250
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
259
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(rel.split("/").first)}")).size
251
260
  [ rel, count ] if count > 0
252
261
  end
253
262
  return nil if rows.empty?
@@ -479,11 +488,11 @@ module RailsAiContext
479
488
  dir = File.join(base_dir, entry)
480
489
  next unless File.directory?(dir)
481
490
 
482
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
491
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(base)}")).size
483
492
  rows[entry] << [ "#{base}/#{entry}", count ] if count > 0
484
493
  end
485
494
 
486
- loose = Dir.glob(File.join(base_dir, TEST_FILE_GLOB)).size
495
+ loose = Dir.glob(File.join(base_dir, test_file_glob(base))).size
487
496
  rows[base] << [ base, loose ] if loose > 0
488
497
  end
489
498
 
@@ -273,11 +273,15 @@ module RailsAiContext
273
273
  # code changed (BaseTool.stale_code_note).
274
274
  def self.instructions
275
275
  intro = "Ground truth engine for Rails apps. Live Prism AST introspection."
276
- return "#{intro} Answers follow edits to the app's files." if CodeReloader.reloadable? || RailsAiContext.static_tier?
277
-
278
- "#{intro} Answers follow edits to the app's files, except what reflection reads, such as associations: " \
279
- "RAILS_ENV=#{RailsAiContext.environment_name} does not reload code, so that stays as of boot, and every " \
280
- "answer says when app code changed since."
276
+ text = if CodeReloader.reloadable? || RailsAiContext.static_tier?
277
+ "#{intro} Answers follow edits to the app's files."
278
+ else
279
+ "#{intro} Answers follow edits to the app's files, except what reflection reads, such as associations: " \
280
+ "RAILS_ENV=#{RailsAiContext.environment_name} does not reload code, so that stays as of boot, and every " \
281
+ "answer says when app code changed since."
282
+ end
283
+ note = UpdateCheck.agent_note
284
+ note ? "#{text} #{note}" : text
281
285
  end
282
286
 
283
287
  # Anything that still escapes a tool (schema validation bugs, SDK-level
@@ -62,23 +62,24 @@ module RailsAiContext
62
62
  pattern = feature.downcase
63
63
  root = rails_app.root.to_s
64
64
  lines = [ "# Feature Analysis: #{feature}", "" ]
65
+ unread = Set.new
65
66
 
66
67
  matched_models = discover_models(ctx, pattern, lines)
67
68
  discover_controllers(ctx, pattern, lines)
68
69
  discover_routes(ctx, pattern, lines)
69
- discover_services(root, pattern, lines)
70
+ discover_services(root, pattern, lines, unread)
70
71
  discover_admin(root, pattern, lines)
71
72
  discover_jobs(ctx, pattern, lines)
72
- discover_views(ctx, root, pattern, lines)
73
+ discover_views(ctx, root, pattern, lines, unread)
73
74
  discover_stimulus(ctx, pattern, lines)
74
- _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines)
75
+ _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines, unread)
75
76
  discover_related_models(ctx, matched_models, lines)
76
77
  discover_concerns(ctx, matched_models, lines)
77
78
  discover_callbacks(ctx, matched_models, lines)
78
79
  discover_channels(ctx, pattern, lines)
79
80
  discover_mailers(ctx, pattern, lines)
80
- discover_env_dependencies(root, pattern, matched_models, lines)
81
- discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, truncated: tests_truncated)
81
+ discover_env_dependencies(root, pattern, matched_models, lines, unread)
82
+ discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, unread, truncated: tests_truncated)
82
83
  discover_components(ctx, pattern, lines)
83
84
 
84
85
  # For auth-related keywords, also discover auth gems
@@ -96,10 +97,17 @@ module RailsAiContext
96
97
  # If nothing was discovered, return a clean "no match" with real suggestions
97
98
  has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
98
99
  unless has_content
99
- return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}")
100
+ return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}#{unread_note(unread)}")
100
101
  end
101
102
 
102
- text_response(lines.join("\n"))
103
+ text_response(lines.join("\n") + unread_note(unread))
104
+ end
105
+
106
+ # Files past MAX_SCAN_FILES in a directory are never read, so a section that is missing may only be unread.
107
+ def self.unread_note(unread)
108
+ return "" if unread.empty?
109
+
110
+ "\n\n_Only the first #{MAX_SCAN_FILES} files of a directory are read, so any past them in #{unread.sort.join(', ')} were not checked._"
103
111
  end
104
112
 
105
113
  # The names a keyword that matched nothing could have been.
@@ -115,6 +123,13 @@ module RailsAiContext
115
123
  class << self
116
124
  private
117
125
 
126
+ # The first MAX_SCAN_FILES of a glob; a directory holding more goes into `unread`.
127
+ def capped_glob(dir, pattern, real_root, label, unread)
128
+ files = safe_glob(dir, pattern, real_root)
129
+ unread << label if files.size > MAX_SCAN_FILES
130
+ files.first(MAX_SCAN_FILES)
131
+ end
132
+
118
133
  # A conditional skip names the condition it is taken out on.
119
134
  def inherited_filter_label(filter)
120
135
  "#{filter[:name]}#{Serializers::SectionFacts.filter_origin(filter)}" \
@@ -260,13 +275,13 @@ module RailsAiContext
260
275
  end
261
276
 
262
277
  # --- AF1: Services ---
263
- def discover_services(root, pattern, lines)
278
+ def discover_services(root, pattern, lines, unread)
264
279
  dir = File.join(root, "app", "services")
265
280
  return unless Dir.exist?(dir)
266
281
 
267
282
  real_root = File.realpath(root).to_s
268
283
  real_dir = File.realpath(dir).to_s
269
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
284
+ candidates = capped_glob(dir, "**/*.rb", real_root, "app/services", unread)
270
285
  # Prefer basename match (fast, no file read) and only fall back to
271
286
  # a content scan for files whose basename doesn't match. This
272
287
  # avoids reading every service file's full contents on large apps.
@@ -287,7 +302,7 @@ module RailsAiContext
287
302
  named = services.select { |s| s[:name] }
288
303
  callers, callers_truncated = GetServicePattern.callers_of(named.to_h { |s| [ s[:name], s[:path] ] }, real_root)
289
304
 
290
- lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
305
+ lines << "## Services (#{found.size}#{unread.include?("app/services") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
291
306
  services.each do |service|
292
307
  relative = service[:path].sub("#{real_root}/", "")
293
308
  methods = service[:name] ? service_methods(service[:source], service[:name]) : []
@@ -353,19 +368,19 @@ module RailsAiContext
353
368
  end
354
369
 
355
370
  # --- AF3: Views + Partials ---
356
- def discover_views(ctx, root, pattern, lines)
371
+ def discover_views(ctx, root, pattern, lines, unread)
357
372
  views_dir = File.join(root, "app", "views")
358
373
  return unless Dir.exist?(views_dir)
359
374
 
360
375
  real_root = File.realpath(root).to_s
361
376
  real_views_dir = File.realpath(views_dir).to_s
362
- candidates = safe_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root).first(MAX_SCAN_FILES)
377
+ candidates = capped_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root, "app/views", unread)
363
378
  found = candidates.select do |path|
364
379
  feature_word_match?(path.sub("#{real_views_dir}/", ""), pattern)
365
380
  end
366
381
  return if found.empty?
367
382
 
368
- lines << "## Views (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
383
+ lines << "## Views (#{found.size}#{unread.include?("app/views") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
369
384
  found.each do |path|
370
385
  relative = path.sub("#{real_views_dir}/", "")
371
386
  source = RailsAiContext::SafeFile.read(path) or next
@@ -439,7 +454,7 @@ module RailsAiContext
439
454
  # @return [Array(Array<String>, Array<String>, Boolean)] the tests the
440
455
  # feature word names, every test read (relative to the suite's root),
441
456
  # and whether a glob stopped at MAX_SCAN_FILES
442
- def discover_tests(root, pattern, lines)
457
+ def discover_tests(root, pattern, lines, unread)
443
458
  real_root = File.realpath(root).to_s
444
459
  found = []
445
460
  all = []
@@ -449,8 +464,7 @@ module RailsAiContext
449
464
  real_base = File.realpath(base).to_s
450
465
  [ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
451
466
  next unless Dir.exist?(dir)
452
- suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base).first(MAX_SCAN_FILES)
453
- truncated = true if suffix_glob.size == MAX_SCAN_FILES
467
+ suffix_glob = capped_glob(dir, "**/*_{test,spec}.rb", real_base, File.basename(dir), unread)
454
468
  # The path, not the basename: the gap checker below matches this
455
469
  # way for the same reason, and a spec whose feature word is a
456
470
  # directory (billing/invoices/create_spec.rb) is the normal shape
@@ -458,13 +472,13 @@ module RailsAiContext
458
472
  suffix_glob.each do |path|
459
473
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
460
474
  end
461
- prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base).first(MAX_SCAN_FILES)
462
- truncated = true if prefix_glob.size == MAX_SCAN_FILES
475
+ prefix_glob = capped_glob(dir, "**/{test,spec}_*.rb", real_base, File.basename(dir), unread)
463
476
  prefix_glob.each do |path|
464
477
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
465
478
  end
466
479
  all.concat(suffix_glob, prefix_glob)
467
480
  end
481
+ truncated = unread.include?("spec") || unread.include?("test")
468
482
  found.uniq!
469
483
  all = all.uniq.map { |path| path.delete_prefix("#{real_base}/") }
470
484
  return [ found, all, truncated ] if found.empty?
@@ -486,7 +500,7 @@ module RailsAiContext
486
500
  end
487
501
 
488
502
  # --- Test coverage gaps ---
489
- def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, truncated: false)
503
+ def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, unread, truncated: false)
490
504
  # A gap is a claim that something has no test, and a scan that stopped
491
505
  # at MAX_SCAN_FILES cannot make it: Discourse has 1,672 spec files, so
492
506
  # the first 500 missed the ones that cover the feature and every
@@ -526,7 +540,7 @@ module RailsAiContext
526
540
  path_dir = File.join(root, "app", dir)
527
541
  next unless Dir.exist?(path_dir)
528
542
 
529
- safe_glob(path_dir, "**/*.rb", real_root).first(MAX_SCAN_FILES).each do |path|
543
+ capped_glob(path_dir, "**/*.rb", real_root, "app/#{dir}", unread).each do |path|
530
544
  snake = File.basename(path, ".rb")
531
545
  next unless feature_word_match?(snake, pattern)
532
546
  next if test_basenames.any? { |t| t.include?(snake) }
@@ -653,17 +667,14 @@ module RailsAiContext
653
667
  end
654
668
 
655
669
  # --- AF9: Environment Dependencies ---
656
- def discover_env_dependencies(root, pattern, matched_models, lines)
670
+ def discover_env_dependencies(root, pattern, matched_models, lines, unread)
657
671
  # Scan services, jobs, and model files for ENV references
658
672
  real_root = File.realpath(root).to_s
659
673
  dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
660
674
  env_vars = Set.new
661
- truncated = false
662
675
 
663
676
  dirs.each do |dir|
664
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
665
- truncated = true if candidates.size == MAX_SCAN_FILES
666
- candidates.each do |path|
677
+ capped_glob(dir, "**/*.rb", real_root, dir.delete_prefix("#{root}/"), unread).each do |path|
667
678
  next unless feature_word_match?(File.basename(path, ".rb"), pattern) || feature_word_match?(path, pattern)
668
679
  source = RailsAiContext::SafeFile.read(path) or next
669
680
  source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
@@ -673,6 +684,7 @@ module RailsAiContext
673
684
  end
674
685
  return if env_vars.empty?
675
686
 
687
+ truncated = dirs.any? { |dir| unread.include?(dir.delete_prefix("#{root}/")) }
676
688
  header = "## Environment Dependencies#{truncated ? " (first #{MAX_SCAN_FILES} per dir scanned)" : ""}"
677
689
  lines << header
678
690
  env_vars.sort.each { |v| lines << "- `#{v}`" }
@@ -186,7 +186,7 @@ module RailsAiContext
186
186
  # Likely cause - enriched with specific inference when possible
187
187
  lines << "## Likely Cause"
188
188
  lines << classification[:likely]
189
- specific = infer_specific_cause(parsed, classification, file, action)
189
+ specific = infer_specific_cause(parsed, classification, file, action, line)
190
190
  lines << "" << "**Specific:** #{specific}" if specific
191
191
  lines << ""
192
192
 
@@ -614,8 +614,18 @@ module RailsAiContext
614
614
  lines << "## #{title}" << "_Could not load: #{e.message}_" << ""
615
615
  end
616
616
 
617
+ # Regex because a lone source line is no program a listener can walk.
618
+ def nil_receiver(file, line, method)
619
+ call = /((?:@@?|\$)?\w+)\.#{Regexp.escape(method)}(?![\w?!])/
620
+ located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
621
+ failing = line.to_i.positive? && located.ok? && RailsAiContext::SafeFile.read(located.realpath).to_s.lines[line.to_i - 1]
622
+ return failing[call, 1] if failing
623
+
624
+ response_text(compose(GetEditContext, file: file, near: method))[call, 1]
625
+ end
626
+
617
627
  # Infer a specific diagnosis from the error + context
618
- def infer_specific_cause(parsed, classification, file, action)
628
+ def infer_specific_cause(parsed, classification, file, action, line = nil)
619
629
  msg = parsed[:message].to_s
620
630
  method = parsed[:method_name]
621
631
 
@@ -629,12 +639,8 @@ module RailsAiContext
629
639
  # Check if calling on an association
630
640
  if method && file
631
641
  begin
632
- ctx = compose(GetEditContext, file: file, near: method)
633
- code = response_text(ctx)
634
- # Find the receiver: something.method_name
635
- receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
636
- if receiver_match
637
- receiver = receiver_match[1]
642
+ receiver = nil_receiver(file, line, method)
643
+ if receiver
638
644
  return "`#{receiver}` is nil when `.#{method}` is called. " \
639
645
  "This variable may not be set in all code paths - check if it's assigned before use, " \
640
646
  "or use `#{receiver}&.#{method}` for safe navigation."
@@ -793,7 +793,7 @@ module RailsAiContext
793
793
  names = res[:login].map { |login| login[:filter][:name] }.join(" and ")
794
794
  "this app's #{res[:helper]} is not known to get a request past #{names}, so send what #{names} checks"
795
795
  else
796
- "this app's tests define no sign-in helper, so sign a user in first the way the app's login does"
796
+ "no `sign_in` or `log_in` helper in this app's test helpers, so sign a user in first the way the app's login does"
797
797
  end
798
798
  [ " # TODO: #{login_phrase(ctrl_class, res)}, so each request here is redirected or refused until the test signs in: #{how}." ]
799
799
  end
@@ -570,7 +570,7 @@ module RailsAiContext
570
570
  lines << "```ruby"
571
571
  lines << "class Add#{written.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
572
572
  lines << " def change"
573
- lines << " add_reference :#{table}, :#{reference}#{foreign_key}"
573
+ lines << " add_reference :#{table}, :#{reference}#{key_type_option(schema, to_table)}#{foreign_key}"
574
574
  lines << " end"
575
575
  lines << "end"
576
576
  lines << "```"
@@ -617,6 +617,27 @@ module RailsAiContext
617
617
  Payload.schema_tables(schema).any? { |_, table, _| table.to_s == name.to_s }
618
618
  end
619
619
 
620
+ # The primary key type of a table when it is not the integer Rails defaults to.
621
+ def custom_key_type(table_data)
622
+ keys = Array(table_data && table_data[:columns]).select { |c| c[:primary_key] }
623
+ type = keys.first[:type].to_s if keys.size == 1
624
+ type unless type.nil? || %w[bigint integer].include?(type)
625
+ end
626
+
627
+ # ", type: :uuid" for a reference to a table keyed that way; a table the schema lacks is keyed as the app's tables are.
628
+ def key_type_option(schema, to_table)
629
+ target = to_table && schema_known?(schema) && Payload.schema_table(schema, to_table)
630
+ type = target ? custom_key_type(target) : app_key_type(schema)
631
+ type ? ", type: :#{type}" : ""
632
+ end
633
+
634
+ # The key type more than half of the app's tables share, when it is not Rails' default.
635
+ def app_key_type(schema)
636
+ tables = schema.is_a?(Hash) && schema[:tables].is_a?(Hash) ? schema[:tables].values : []
637
+ type, count = tables.map { |data| custom_key_type(data) }.tally.max_by(&:last)
638
+ type if type && count * 2 > tables.size
639
+ end
640
+
620
641
  def generate_change_type(table, column, type, options)
621
642
  return [ "**Error:** column and type are required" ] unless column && type
622
643
 
@@ -667,7 +688,9 @@ module RailsAiContext
667
688
  lines << "```ruby"
668
689
  lines << "class Create#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
669
690
  lines << " def change"
670
- lines << " create_table :#{table} do |t|"
691
+ schema = cached_context[:schema]
692
+ key_type = app_key_type(schema)
693
+ lines << " create_table :#{table}#{", id: :#{key_type}" if key_type} do |t|"
671
694
 
672
695
  if columns_str
673
696
  columns_str.split(",").each do |col|
@@ -675,7 +698,7 @@ module RailsAiContext
675
698
  name = parts[0]&.strip
676
699
  type = parts[1]&.strip || "string"
677
700
  if type == "references"
678
- lines << " t.references :#{name}, foreign_key: true"
701
+ lines << " t.references :#{name}#{key_type_option(schema, name.to_s.pluralize)}, foreign_key: true"
679
702
  else
680
703
  lines << " t.#{type} :#{name}"
681
704
  end
@@ -46,7 +46,8 @@ module RailsAiContext
46
46
  else
47
47
  compose_standard(ctx)
48
48
  end
49
- text_response(body, suffix: introspection_warnings_note(ctx))
49
+ update = RailsAiContext::UpdateCheck.agent_note
50
+ text_response(body, suffix: [ introspection_warnings_note(ctx), ("\n\n---\n#{update}" if update) ].compact.join)
50
51
  end
51
52
 
52
53
  class << self
@@ -21,7 +21,7 @@ module RailsAiContext
21
21
  files: {
22
22
  type: "array",
23
23
  items: { type: "string" },
24
- description: "Filter to specific files (relative to Rails root). Omit to review all changed files."
24
+ description: "Filter to files or directories (relative to Rails root). Omit to review all changed files."
25
25
  }
26
26
  }
27
27
  )
@@ -91,7 +91,7 @@ module RailsAiContext
91
91
  end
92
92
 
93
93
  changed = get_changed_files(base, root)
94
- changed = changed.select { |f| files.any? { |filter| f.include?(filter) } } if files&.any?
94
+ changed = changed.select { |f| files.any? { |filter| path_matches?(f, filter) } } if files&.any?
95
95
 
96
96
  if changed.empty?
97
97
  return text_response("No changes found for ref '#{echo_input(ref)}'.#{files ? " Filter: #{echo_input(files.join(', '))}" : ""}")
@@ -214,6 +214,12 @@ module RailsAiContext
214
214
  status.success? && !prefix.strip.empty?
215
215
  end
216
216
 
217
+ # A filter names one file or a directory, never part of a name: `Gemfile` is not `Gemfile.lock`.
218
+ def path_matches?(path, filter)
219
+ wanted = filter.delete_prefix("./").chomp("/")
220
+ path == wanted || path.start_with?("#{wanted}/")
221
+ end
222
+
217
223
  # The Ruby folders count only Ruby files: the install writes app/models/AGENTS.md beside the models.
218
224
  def classify_file(path)
219
225
  case path
@@ -23,6 +23,11 @@ module RailsAiContext
23
23
  BINARY_PROBE_BYTES = 64 * 1024
24
24
 
25
25
  TEST_DIRS = %w[test/ spec/ features/].freeze
26
+
27
+ # A trace lists this many internal calls and sibling methods, then says how many it left out.
28
+ INTERNAL_CALLS_SHOWN = 15
29
+ SIBLINGS_SHOWN = 10
30
+
26
31
  MATCH_FIELD_SEPARATOR = "\x1f"
27
32
  # A comment line by the file's own syntax. `#{` and ` * ` are Ruby code;
28
33
  # `#id` is a selector in a stylesheet and an element in Haml or Slim.
@@ -768,7 +773,8 @@ module RailsAiContext
768
773
 
769
774
  if internal_calls.any?
770
775
  lines << "" << "## Calls internally"
771
- internal_calls.first(15).each { |c| lines << "- `#{c}`" }
776
+ internal_calls.first(INTERNAL_CALLS_SHOWN).each { |c| lines << "- `#{c}`" }
777
+ lines << "- _...and #{internal_calls.size - INTERNAL_CALLS_SHOWN} more_" if internal_calls.size > INTERNAL_CALLS_SHOWN
772
778
  end
773
779
  end
774
780
 
@@ -776,7 +782,8 @@ module RailsAiContext
776
782
  siblings = extract_sibling_methods(File.join(root, r[:file]), cleaned)
777
783
  if siblings.any?
778
784
  lines << "" << "## Sibling methods (same file)"
779
- siblings.first(10).each { |s| lines << "- `#{s}`" }
785
+ siblings.first(SIBLINGS_SHOWN).each { |s| lines << "- `#{s}`" }
786
+ lines << "- _...and #{siblings.size - SIBLINGS_SHOWN} more_" if siblings.size > SIBLINGS_SHOWN
780
787
  end
781
788
 
782
789
  lines << ""
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "version"
5
+
6
+ module RailsAiContext
7
+ # Tells the copy `gem install` put on PATH that rubygems.org has a newer
8
+ # release. Only the gem's own binary, run outside a bundle, turns it on
9
+ # (enable!), so a copy an app's bundle loads never checks: its lockfile
10
+ # decides the version.
11
+ #
12
+ # Core Ruby only. The binary loads this before the app boots, and requiring
13
+ # net/http or json here would activate the newest installed versions over
14
+ # the ones the app's lock pins. The fetch runs in a detached child instead,
15
+ # at most once a day, and the version it writes is what the next check reads.
16
+ module UpdateCheck
17
+ OPT_OUT_ENV = "RAILS_AI_CONTEXT_NO_UPDATE_CHECK"
18
+ URL = "https://rubygems.org/api/v1/versions/rails-ai-context/latest.json"
19
+ INTERVAL = 24 * 60 * 60
20
+
21
+ FETCH = <<~RUBY
22
+ require "net/http"
23
+ path, url = ARGV
24
+ uri = URI(url)
25
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
26
+ http.get(uri.request_uri)
27
+ end
28
+ version = response.body[/"version"\\s*:\\s*"([^"]+)"/, 1] if response.is_a?(Net::HTTPSuccess)
29
+ exit unless version
30
+ tmp = "\#{path}.\#{Process.pid}"
31
+ begin
32
+ File.write(tmp, version)
33
+ File.rename(tmp, path)
34
+ ensure
35
+ File.delete(tmp) if File.exist?(tmp)
36
+ end
37
+ RUBY
38
+
39
+ module_function
40
+
41
+ # Called before an app's boot, which swaps Gem.path for the bundle's.
42
+ def enable!
43
+ @enabled = true
44
+ @gem_dirs = Gem.path
45
+ newest_at_start
46
+ end
47
+
48
+ # The notice for an agent to pass on. Giving it marks it told, so the CLI does not print it again.
49
+ def agent_note
50
+ note = notice or return
51
+ @told = true
52
+ "Tell the user: #{note}"
53
+ end
54
+
55
+ def told?
56
+ @told == true
57
+ end
58
+
59
+ def disabled?
60
+ !@enabled || !ENV[OPT_OUT_ENV].to_s.empty? || ci? || Gem::Version.new(VERSION).prerelease?
61
+ end
62
+
63
+ # Starts the fetch when the last one is a day old (or in the future, after a
64
+ # clock change). The file's time is set first, so a failed fetch waits a day
65
+ # too. Its own process group keeps a Ctrl-C at the terminal from killing it.
66
+ # @param env [Hash] the environment the binary started in, before an app's boot changed it
67
+ def refresh(env:)
68
+ return if disabled?
69
+
70
+ path = state_file
71
+ age = Time.now - File.mtime(path) if File.exist?(path)
72
+ return if age&.between?(0, INTERVAL)
73
+
74
+ make_dirs(File.dirname(path))
75
+ File.open(path, "a") { nil }
76
+ File.utime(nil, nil, path)
77
+ group = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
78
+ pid = Process.spawn(env, RbConfig.ruby, "-e", FETCH, path, URL,
79
+ unsetenv_others: true, in: File::NULL, out: File::NULL, err: File::NULL, **group)
80
+ Process.detach(pid)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ # One sentence for the user, or nil when this copy is current.
86
+ def notice
87
+ return if disabled?
88
+
89
+ latest = latest_release or return
90
+ running = Gem::Version.new(VERSION)
91
+ return unless latest > running
92
+ # A newer copy installed when this process started means an older one was run on purpose (`_5.30.0_`).
93
+ return if newest_at_start && newest_at_start > running
94
+
95
+ installed = newest_installed
96
+ if installed && installed >= latest
97
+ "rails-ai-context #{installed} is installed, but this process still runs #{VERSION}. " \
98
+ "Restart the MCP server (or the AI client) to load it."
99
+ else
100
+ "rails-ai-context #{latest} is out; this is #{VERSION} on Ruby #{RUBY_VERSION} (#{RbConfig.ruby}). " \
101
+ "Under that Ruby run `gem update rails-ai-context`, then `rails-ai-context init --defaults` in each app. " \
102
+ "#{OPT_OUT_ENV}=1 turns this check off."
103
+ end
104
+ end
105
+
106
+ def latest_release
107
+ text = File.read(state_file, 64).to_s.strip
108
+ return if text.empty? || !Gem::Version.correct?(text)
109
+
110
+ version = Gem::Version.new(text)
111
+ version unless version.prerelease?
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ def state_file
117
+ home = Gem.respond_to?(:state_home) ? Gem.state_home : (ENV["XDG_STATE_HOME"] || File.join(Gem.user_home, ".local", "state"))
118
+ File.join(home, "rails-ai-context", "latest")
119
+ end
120
+
121
+ # The newest copy installed for this Ruby, in any directory of the gem path.
122
+ def newest_installed
123
+ Array(@gem_dirs).flat_map { |dir| Dir[File.join(dir, "specifications", "rails-ai-context-*.gemspec")] }.filter_map do |spec|
124
+ number = File.basename(spec, ".gemspec").delete_prefix("rails-ai-context-")
125
+ Gem::Version.new(number) if Gem::Version.correct?(number)
126
+ end.max
127
+ end
128
+
129
+ def newest_at_start
130
+ return @newest_at_start if defined?(@newest_at_start)
131
+
132
+ @newest_at_start = newest_installed
133
+ end
134
+
135
+ def ci?
136
+ defined?(Gem::CIDetector) ? Gem::CIDetector.ci? : !ENV["CI"].to_s.empty?
137
+ end
138
+
139
+ # Dir.mkdir one level at a time: fileutils is a gem an app may pin.
140
+ def make_dirs(dir)
141
+ return if Dir.exist?(dir)
142
+
143
+ make_dirs(File.dirname(dir))
144
+ Dir.mkdir(dir)
145
+ rescue Errno::EEXIST
146
+ nil
147
+ end
148
+ end
149
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.36.2"
4
+ VERSION = "5.36.4"
5
5
  end
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.36.2
4
+ version: 5.36.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -560,6 +560,7 @@ files:
560
560
  - lib/rails_ai_context/tools/session_context.rb
561
561
  - lib/rails_ai_context/tools/validate.rb
562
562
  - lib/rails_ai_context/tools/validate_semantics.rb
563
+ - lib/rails_ai_context/update_check.rb
563
564
  - lib/rails_ai_context/version.rb
564
565
  - lib/rails_ai_context/vfs.rb
565
566
  - lib/rails_ai_context/view_file.rb