rails-ai-context 5.36.3 → 5.36.5

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: ecc8cfb0b751983a682c2a2c0bd2082216595b83f12f679d190506425d60ae48
4
- data.tar.gz: e0674fed2e5f40ebe3f33166c59d9c17bcd5c5160352100cf8b38d87a73387a4
3
+ metadata.gz: d19c2da6b45c1d8622c9d97bf31f005a10a904c1c6361c9f2d4d75819bdb236d
4
+ data.tar.gz: cdc5979f3619e5823f7a3b158864e4c0fe1b2e62037074925334dcfbef50ce2b
5
5
  SHA512:
6
- metadata.gz: c78d50c7e050632c88f60e901a8805ed5d397f2f12fb9412fa7ac64af1ed3921ff776486a8e4912f0a5a531e6a90661ca6d7b35def872bc4cd11215bde2941b0
7
- data.tar.gz: 0b597f6cc7372007cb84936ac62f2ca893791622c62a667ea4601b545c73a9719949f3abfc09fd8fe75fe2e143fb1bfa5a4bbf82ee47e4f5fca6d293392c5b9d
6
+ metadata.gz: bd790f4fe94ffd61064e3d65d62335de351af8c7d90669370c4b42c6cb37b1284dbf62c6ca3160d1ac7ee801dbf378c40764b361f6903197716823849246d238
7
+ data.tar.gz: '083dcf1bdcbe006da36854877cae12ae6bdea0282f8411686609e85b4fd029adf62f430f3c5780c78f624059757b46ec58bb188d48a017a7e9451e1ee3253bb4'
data/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ 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.5] - 2026-10-19
9
+
10
+ ### Added
11
+
12
+ - **`rails_get_edit_context` takes a `line`.** It shows `context_lines` either
13
+ side of that line, so a line from a stack trace needs no text to search for.
14
+ `near` is needed only without one.
15
+
16
+ ### Fixed
17
+
18
+ - **`rails_diagnose` shows the code around the line it was given** (#495). It
19
+ searched for the method name and opened on its first mention, a comment
20
+ included, so the failing line was missing from the code shown in 18 of 30
21
+ real calls on one app. It also reads a setter's name (`title=`) and an index
22
+ call's (`[]`), and names their nil receiver.
23
+ - **`rails_migration_advisor` points a foreign key at the key of a table not
24
+ keyed by `id`** (#496): `foreign_key: { primary_key: :bid }`, where
25
+ `foreign_key: true` made PostgreSQL refuse the migration. A table with a
26
+ composite primary key, and a `t.references` table the schema lacks, get no
27
+ foreign key and a line saying why.
28
+ - **`rails_migration_advisor` types a reference the way the key it points at
29
+ is typed** (#501). A reference to an `integer` key gets `type: :integer`
30
+ and the key's `limit:` when it has one, and on MySQL one to an `unsigned`
31
+ key gets `unsigned: true`. It wrote Rails' default signed bigint, and MySQL
32
+ and MariaDB refuse a foreign key whose column differs from the key, so the
33
+ migration failed. PostgreSQL, which accepted the bigint, now gets the
34
+ matching type too. A `serial` key gives `integer` and a `smallserial` one
35
+ `smallint`, where the reference used to get a sequence of its own that
36
+ filled it with a made-up owner. SQLite, where every key is an integer,
37
+ still gets no type.
38
+ - **`rails_analyze_feature` lists every service, view and spec whose name
39
+ matches** (#497), wherever it sorts. Only the files it reads stop at 500 per
40
+ directory, and the closing note says which directory past them was matched
41
+ by name alone. The test gap list reads the name of every test, so it is no
42
+ longer left out on a suite of more than 500.
43
+ - **`conventions` counts the TypeScript, JSX and Vue files of `app/javascript`**
44
+ (#498), and the `.ruby` templates of `app/views`. Mastodon's `app/javascript`
45
+ read 78 files; it holds 922. An edit to one of those files, or to a
46
+ `.jbuilder` or `.builder` view, now refreshes the cached answers, as one to a
47
+ `.js` or `.erb` file did.
48
+ - **`rails_migration_advisor`'s Strong Migrations warning for `add_association`
49
+ rewrites the `add_reference` it wrote** (#499). It named the reference as the
50
+ table (`add_foreign_key :listings, :owner`) and added a second key after the
51
+ migration had added one. On PostgreSQL it now gives the reference with
52
+ `disable_ddl_transaction!` and a concurrent index, the key in its own
53
+ `add_foreign_key ... validate: false` (strong_migrations before 2.5.2 flags any
54
+ foreign key inside `add_reference`), then `validate_foreign_key` with
55
+ the real table and column. On MySQL it moves the key to a separate
56
+ migration, as strong_migrations 2.7 and later require; an older locked version,
57
+ which does not check a MySQL key, gets no warning. A reference with no
58
+ foreign key gets only the concurrent index on PostgreSQL and nothing on
59
+ MySQL, and SQLite, which strong_migrations does not check, gets nothing. A
60
+ reference whose column already exists gets no warning, since its migration
61
+ fails first, and an undecided database gets one line when the reference has
62
+ a key.
63
+
64
+ ## [5.36.4] - 2026-10-16
65
+
66
+ ### Added
67
+
68
+ - **The standalone binary says when a newer release is out.** A copy installed
69
+ with `gem install` stayed at its version until someone thought to update it.
70
+ It now asks rubygems.org for the latest version at most once a day, in a
71
+ detached child process, and keeps the answer under `$XDG_STATE_HOME`
72
+ (`~/.local/state/rails-ai-context/latest`). A command run in a terminal prints
73
+ one line on stderr naming the Ruby to update under and the two commands to
74
+ run. The MCP server adds the line to its instructions and to the end of
75
+ `rails_onboard` for the agent to pass on, or asks for a restart when the new
76
+ version is already installed. `RAILS_AI_CONTEXT_NO_UPDATE_CHECK=1` turns it
77
+ off. It is off in CI, on a prerelease, for an older version run on purpose
78
+ (`_5.30.0_`) and in a copy the app's bundle runs. Nothing is installed for you.
79
+
80
+ ### Fixed
81
+
82
+ - **The code reads prism 2 the way it reads prism 1.** prism's main branch
83
+ (2.0.0.rc1) parses with the running Ruby's grammar when it is given none,
84
+ replaced `MissingNode` with `ErrorRecoveryNode`, and leaves an unterminated
85
+ heredoc without a closing location. The gem now names the newest grammar
86
+ when it parses and lexes, reads either recovery node, and ends an
87
+ unterminated heredoc at its body. The gemspec still requires prism below 2.0.
88
+
8
89
  ## [5.36.3] - 2026-10-15
9
90
 
10
91
  ### 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.
@@ -340,6 +340,20 @@ Proof sources:
340
340
  with Rails 7.0 and 7.2, Ruby 3.3 with Rails 7.1, Ruby 3.4 with Rails 8.0 and
341
341
  8.1, and Ruby 4.0 with Rails 8.1; and the unit suite and the E2E harness on
342
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.
349
+ In the v5.36.5 release QA: each fix's example failing on the code before
350
+ it; the advice `rails_migration_advisor` writes run as real migrations
351
+ under strong_migrations 1.8 to 2.8 and Active Record 7.0 to 8.1 against
352
+ PostgreSQL 13, 16 and 17, MySQL 8.0 and 8.4, MariaDB 10.6 and 11.4 and
353
+ SQLite, for bigint, integer, unsigned, serial and uuid keys; its answers on
354
+ three corpus apps in the static tier; the unit suite on Ruby 3.1 to 4.0
355
+ with Rails 7.0 to 8.1 on macOS, with seeds 1, 27377 and 90210 and defined
356
+ order; and the unit suite and the E2E harness on Linux, macOS and Windows.
343
357
  2. Non-crash coverage for every built-in tool including `get_view` in
344
358
  `spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
345
359
  (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` |
data/docs/GUIDE.md CHANGED
@@ -700,8 +700,9 @@ Returns just enough context to make a surgical Edit to a file. Returns the targe
700
700
  | Param | Type | Description |
701
701
  |-------|------|-------------|
702
702
  | `file` | string | **Required.** File path relative to Rails root (e.g. `app/models/post.rb`). |
703
- | `near` | string | **Required.** What to find - a method name, keyword, or string to locate (e.g. `scope`, `def index`). |
703
+ | `near` | string | **Required** unless `line` is given. What to find - a method name, keyword, or string to locate (e.g. `scope`, `def index`). |
704
704
  | `context_lines` | integer | Lines of context above and below the match. Default: 5. |
705
+ | `line` | integer | Show `context_lines` either side of this line number instead of searching for `near`. |
705
706
 
706
707
  **Examples:**
707
708
 
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
@@ -104,8 +104,9 @@ Method-aware code extraction with surrounding class context. Every line returned
104
104
  | Parameter | Type | Default | Description |
105
105
  |:----------|:-----|:--------|:------------|
106
106
  | `file` | string | *required* | File path relative to Rails root |
107
- | `near` | string | *required* | What to find in the file: a method name, keyword or string (e.g. `def index`, `validates`, `STATUSES`) |
107
+ | `near` | string | *required* unless `line` | What to find in the file: a method name, keyword or string (e.g. `def index`, `validates`, `STATUSES`) |
108
108
  | `context_lines` | integer | `5` | Lines of context above and below the match |
109
+ | `line` | integer | - | Show `context_lines` either side of this line instead of searching for `near`; past the end of the file, `near` is searched for |
109
110
 
110
111
  <p align="right"><a href="#table-of-contents">↑ back to top</a></p>
111
112
 
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
 
@@ -50,7 +50,7 @@ module RailsAiContext
50
50
  # a change and a walk that names it read the same tree.
51
51
  # sql: a structure dump, under whatever name database.yml's schema_dump gives it.
52
52
  # tt: a generator template override under lib/templates.
53
- WATCHED_EXTNAMES = %w[.rb .rake .js .ts .erb .haml .slim .yml .sql .tt].freeze
53
+ WATCHED_EXTNAMES = %w[.rb .rake .js .jsx .mjs .cjs .ts .tsx .vue .svelte .erb .haml .slim .jbuilder .builder .ruby .yml .sql .tt].freeze
54
54
  WATCHED_EXTENSIONS = "**/*{#{WATCHED_EXTNAMES.join(",")}}"
55
55
 
56
56
  # jsbundling and cssbundling rewrite this on every frontend save, and no reader reads it.
@@ -172,11 +172,12 @@ module RailsAiContext
172
172
  RailsAiContext.debug_fail(e, false, label: "uses_async_queries?")
173
173
  end
174
174
 
175
+ SCRIPT_FILES = "**/*.{js,jsx,mjs,cjs,ts,tsx,vue,svelte}"
175
176
  # Files a directory holds besides Ruby, which SourceScan leaves out.
176
177
  NON_RUBY_FILES = {
177
- "app/javascript" => "**/*.js",
178
- "app/javascript/controllers" => "**/*.js",
179
- "app/views" => "**/*.{erb,haml,slim,jbuilder,builder}",
178
+ "app/javascript" => SCRIPT_FILES,
179
+ "app/javascript/controllers" => SCRIPT_FILES,
180
+ "app/views" => "**/*.{erb,haml,slim,jbuilder,builder,ruby}",
180
181
  "lib/tasks" => "**/*.rake"
181
182
  }.freeze
182
183
 
@@ -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,
@@ -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
@@ -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
@@ -33,12 +33,8 @@ module RailsAiContext
33
33
  # Map well-known feature keywords to gem-based patterns
34
34
  AUTH_KEYWORDS = %w[auth authentication login signup signin session devise omniauth].freeze
35
35
 
36
- # Cap per-directory file scans to avoid unbounded work on large monorepos.
37
- # Note: Dir.glob materialises the full path array before .first() truncates.
38
- # the directory walk itself is unbounded; only per-file reading and processing
39
- # is capped at MAX_SCAN_FILES. Applied to every discover_* method that walks
40
- # the filesystem. Tool output notes when the cap is hit so the AI agent knows
41
- # to narrow its feature keyword. v5.8.1 hardening; all glob sites in r2.
36
+ # Names are matched over every file a glob finds; only the files read per directory, and the
37
+ # matches listed per section, stop at MAX_SCAN_FILES, and the answer says where they did.
42
38
  MAX_SCAN_FILES = 500
43
39
  # The callers named under each service; rails_get_service_pattern(service:) lists more.
44
40
  CALLERS_SHOWN = 5
@@ -70,16 +66,16 @@ module RailsAiContext
70
66
  discover_services(root, pattern, lines, unread)
71
67
  discover_admin(root, pattern, lines)
72
68
  discover_jobs(ctx, pattern, lines)
73
- discover_views(ctx, root, pattern, lines, unread)
69
+ discover_views(ctx, root, pattern, lines)
74
70
  discover_stimulus(ctx, pattern, lines)
75
- _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines, unread)
71
+ all_tests = discover_tests(root, pattern, lines)
76
72
  discover_related_models(ctx, matched_models, lines)
77
73
  discover_concerns(ctx, matched_models, lines)
78
74
  discover_callbacks(ctx, matched_models, lines)
79
75
  discover_channels(ctx, pattern, lines)
80
76
  discover_mailers(ctx, pattern, lines)
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)
77
+ discover_env_dependencies(root, pattern, matched_models, lines)
78
+ discover_test_gaps(root, pattern, matched_models, ctx, all_tests, lines)
83
79
  discover_components(ctx, pattern, lines)
84
80
 
85
81
  # For auth-related keywords, also discover auth gems
@@ -103,11 +99,11 @@ module RailsAiContext
103
99
  text_response(lines.join("\n") + unread_note(unread))
104
100
  end
105
101
 
106
- # Files past MAX_SCAN_FILES in a directory are never read, so a section that is missing may only be unread.
102
+ # Files past MAX_SCAN_FILES in a directory are matched by name alone, so a match by content may be missing.
107
103
  def self.unread_note(unread)
108
104
  return "" if unread.empty?
109
105
 
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._"
106
+ "\n\n_Only the first #{MAX_SCAN_FILES} files of a directory are read, so any past them in #{unread.sort.join(', ')} were matched by name alone._"
111
107
  end
112
108
 
113
109
  # The names a keyword that matched nothing could have been.
@@ -123,12 +119,7 @@ module RailsAiContext
123
119
  class << self
124
120
  private
125
121
 
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
122
+ def listed_suffix(found) = found.size > MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} listed" : ""
132
123
 
133
124
  # A conditional skip names the condition it is taken out on.
134
125
  def inherited_filter_label(filter)
@@ -281,17 +272,17 @@ module RailsAiContext
281
272
 
282
273
  real_root = File.realpath(root).to_s
283
274
  real_dir = File.realpath(dir).to_s
284
- candidates = capped_glob(dir, "**/*.rb", real_root, "app/services", unread)
285
- # Prefer basename match (fast, no file read) and only fall back to
286
- # a content scan for files whose basename doesn't match. This
287
- # avoids reading every service file's full contents on large apps.
288
- found = candidates.select do |path|
289
- next true if feature_word_match?(File.basename(path, ".rb"), pattern)
275
+ files = safe_glob(dir, "**/*.rb", real_root)
276
+ # A name matches without a read; only the files whose name does not are read for the word.
277
+ by_name, rest = files.partition { |path| feature_word_match?(File.basename(path, ".rb"), pattern) }
278
+ unread << "app/services" if rest.size > MAX_SCAN_FILES
279
+ by_content = rest.first(MAX_SCAN_FILES).select do |path|
290
280
  File.size(path) < 50_000 && feature_word_match?(RailsAiContext::SafeFile.read(path), pattern)
291
281
  end
282
+ found = files & (by_name + by_content)
292
283
  return if found.empty?
293
284
 
294
- services = found.filter_map do |path|
285
+ services = found.first(MAX_SCAN_FILES).filter_map do |path|
295
286
  source = RailsAiContext::SafeFile.read(path) or next
296
287
  # The path carries the namespace Zeitwerk expects, as rails_get_service_pattern reads it.
297
288
  constant = path.delete_prefix("#{real_dir}/").delete_suffix(".rb").camelize
@@ -302,7 +293,7 @@ module RailsAiContext
302
293
  named = services.select { |s| s[:name] }
303
294
  callers, callers_truncated = GetServicePattern.callers_of(named.to_h { |s| [ s[:name], s[:path] ] }, real_root)
304
295
 
305
- lines << "## Services (#{found.size}#{unread.include?("app/services") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
296
+ lines << "## Services (#{found.size}#{listed_suffix(found)})"
306
297
  services.each do |service|
307
298
  relative = service[:path].sub("#{real_root}/", "")
308
299
  methods = service[:name] ? service_methods(service[:source], service[:name]) : []
@@ -368,20 +359,19 @@ module RailsAiContext
368
359
  end
369
360
 
370
361
  # --- AF3: Views + Partials ---
371
- def discover_views(ctx, root, pattern, lines, unread)
362
+ def discover_views(ctx, root, pattern, lines)
372
363
  views_dir = File.join(root, "app", "views")
373
364
  return unless Dir.exist?(views_dir)
374
365
 
375
366
  real_root = File.realpath(root).to_s
376
367
  real_views_dir = File.realpath(views_dir).to_s
377
- candidates = capped_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root, "app/views", unread)
378
- found = candidates.select do |path|
368
+ found = safe_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root).select do |path|
379
369
  feature_word_match?(path.sub("#{real_views_dir}/", ""), pattern)
380
370
  end
381
371
  return if found.empty?
382
372
 
383
- lines << "## Views (#{found.size}#{unread.include?("app/views") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
384
- found.each do |path|
373
+ lines << "## Views (#{found.size}#{listed_suffix(found)})"
374
+ found.first(MAX_SCAN_FILES).each do |path|
385
375
  relative = path.sub("#{real_views_dir}/", "")
386
376
  source = RailsAiContext::SafeFile.read(path) or next
387
377
  line_count = source.lines.size
@@ -451,20 +441,17 @@ module RailsAiContext
451
441
  relative.sub(%r{(\A|/)(?:spec|test)_}, "\\1")
452
442
  end
453
443
 
454
- # @return [Array(Array<String>, Array<String>, Boolean)] the tests the
455
- # feature word names, every test read (relative to the suite's root),
456
- # and whether a glob stopped at MAX_SCAN_FILES
457
- def discover_tests(root, pattern, lines, unread)
444
+ # @return [Array<String>] every test in the suite, relative to the suite's root
445
+ def discover_tests(root, pattern, lines)
458
446
  real_root = File.realpath(root).to_s
459
447
  found = []
460
448
  all = []
461
- truncated = false
462
449
 
463
450
  base = RailsAiContext::PathResolver.test_root(root)
464
451
  real_base = File.realpath(base).to_s
465
452
  [ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
466
453
  next unless Dir.exist?(dir)
467
- suffix_glob = capped_glob(dir, "**/*_{test,spec}.rb", real_base, File.basename(dir), unread)
454
+ suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base)
468
455
  # The path, not the basename: the gap checker below matches this
469
456
  # way for the same reason, and a spec whose feature word is a
470
457
  # directory (billing/invoices/create_spec.rb) is the normal shape
@@ -472,20 +459,18 @@ module RailsAiContext
472
459
  suffix_glob.each do |path|
473
460
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
474
461
  end
475
- prefix_glob = capped_glob(dir, "**/{test,spec}_*.rb", real_base, File.basename(dir), unread)
462
+ prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base)
476
463
  prefix_glob.each do |path|
477
464
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
478
465
  end
479
466
  all.concat(suffix_glob, prefix_glob)
480
467
  end
481
- truncated = unread.include?("spec") || unread.include?("test")
482
468
  found.uniq!
483
469
  all = all.uniq.map { |path| path.delete_prefix("#{real_base}/") }
484
- return [ found, all, truncated ] if found.empty?
470
+ return all if found.empty?
485
471
 
486
- header = "## Tests (#{found.size}#{truncated ? " - first #{MAX_SCAN_FILES} per glob scanned" : ""})"
487
- lines << header
488
- found.each do |path|
472
+ lines << "## Tests (#{found.size}#{listed_suffix(found)})"
473
+ found.first(MAX_SCAN_FILES).each do |path|
489
474
  relative = Pathname.new(path).relative_path_from(Pathname.new(real_root)).to_s
490
475
  source = RailsAiContext::SafeFile.read(path) or next
491
476
  test_count = source.each_line.count do |line|
@@ -494,19 +479,15 @@ module RailsAiContext
494
479
  lines << "- `#{relative}` (#{count_phrase(test_count, "test")})"
495
480
  end
496
481
  lines << ""
497
- [ found, all, truncated ]
482
+ all
498
483
  rescue => e
499
- RailsAiContext.debug_fail(e, [ [], [], false ], label: "discover_tests")
484
+ RailsAiContext.debug_fail(e, [], label: "discover_tests")
500
485
  end
501
486
 
502
487
  # --- Test coverage gaps ---
503
- def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, unread, truncated: false)
504
- # A gap is a claim that something has no test, and a scan that stopped
505
- # at MAX_SCAN_FILES cannot make it: Discourse has 1,672 spec files, so
506
- # the first 500 missed the ones that cover the feature and every
507
- # controller was reported as untested.
508
- return if truncated
509
-
488
+ # A gap is a claim that something has no test, so it reads the name of every test in the suite:
489
+ # Discourse has 1,672 spec files, and the first 500 missed the ones that cover the feature.
490
+ def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines)
510
491
  gaps = []
511
492
  # Paths, not basenames: a namespaced name underscores to admin/badges,
512
493
  # which no basename can contain, so every namespaced controller was
@@ -540,7 +521,7 @@ module RailsAiContext
540
521
  path_dir = File.join(root, "app", dir)
541
522
  next unless Dir.exist?(path_dir)
542
523
 
543
- capped_glob(path_dir, "**/*.rb", real_root, "app/#{dir}", unread).each do |path|
524
+ safe_glob(path_dir, "**/*.rb", real_root).each do |path|
544
525
  snake = File.basename(path, ".rb")
545
526
  next unless feature_word_match?(snake, pattern)
546
527
  next if test_basenames.any? { |t| t.include?(snake) }
@@ -667,15 +648,19 @@ module RailsAiContext
667
648
  end
668
649
 
669
650
  # --- AF9: Environment Dependencies ---
670
- def discover_env_dependencies(root, pattern, matched_models, lines, unread)
651
+ def discover_env_dependencies(root, pattern, matched_models, lines)
671
652
  # Scan services, jobs, and model files for ENV references
672
653
  real_root = File.realpath(root).to_s
673
654
  dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
674
655
  env_vars = Set.new
656
+ truncated = false
675
657
 
676
658
  dirs.each do |dir|
677
- capped_glob(dir, "**/*.rb", real_root, dir.delete_prefix("#{root}/"), unread).each do |path|
678
- next unless feature_word_match?(File.basename(path, ".rb"), pattern) || feature_word_match?(path, pattern)
659
+ matched = safe_glob(dir, "**/*.rb", real_root).select do |path|
660
+ feature_word_match?(File.basename(path, ".rb"), pattern) || feature_word_match?(path, pattern)
661
+ end
662
+ truncated ||= matched.size > MAX_SCAN_FILES
663
+ matched.first(MAX_SCAN_FILES).each do |path|
679
664
  source = RailsAiContext::SafeFile.read(path) or next
680
665
  source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
681
666
  env_vars << (m[0] || m[1])
@@ -684,7 +669,6 @@ module RailsAiContext
684
669
  end
685
670
  return if env_vars.empty?
686
671
 
687
- truncated = dirs.any? { |dir| unread.include?(dir.delete_prefix("#{root}/")) }
688
672
  header = "## Environment Dependencies#{truncated ? " (first #{MAX_SCAN_FILES} per dir scanned)" : ""}"
689
673
  lines << header
690
674
  env_vars.sort.each { |v| lines << "- `#{v}`" }
@@ -220,7 +220,7 @@ module RailsAiContext
220
220
 
221
221
  # Next steps
222
222
  next_steps = [
223
- (pointer("rails_get_edit_context", %(file:"#{file}", near:"#{parsed[:method_name] || line || parsed[:exception_class]}"), "to see the code.") if file),
223
+ (pointer("rails_get_edit_context", line ? %(file:"#{file}", line:#{line}) : %(file:"#{file}", near:"#{parsed[:method_name] || parsed[:exception_class]}"), "to see the code.") if file),
224
224
  (pointer("rails_search_code", %(pattern:"#{parsed[:method_name]}", match_type:"trace"), "to trace the method.") if parsed[:method_name])
225
225
  ].compact.map { |step| "_Use #{step}_" }
226
226
  if next_steps.any?
@@ -283,7 +283,7 @@ module RailsAiContext
283
283
  end
284
284
 
285
285
  # Extract method name from "undefined method `foo`" or "undefined method 'foo'"
286
- if (m = result[:message].match(/undefined method [`'](\w+[?!]?)[`']/))
286
+ if (m = result[:message].match(/undefined method [`'](\w+[?!=]?|\[\]=?)[`']/))
287
287
  result[:method_name] = m[1]
288
288
  end
289
289
 
@@ -497,7 +497,7 @@ module RailsAiContext
497
497
  known = known_model_methods(model_data)
498
498
  # "published?" / "save!" resolve through the bare attribute name, so
499
499
  # compare without the trailing punctuation too.
500
- base_name = method_name.to_s.sub(/[?!]\z/, "")
500
+ base_name = method_name.to_s.sub(/[?!=]\z/, "")
501
501
  return nil if known.include?(base_name) || known.include?(method_name.to_s)
502
502
 
503
503
  suggestion = find_closest_match(base_name, known)
@@ -575,7 +575,7 @@ module RailsAiContext
575
575
 
576
576
  if file && line
577
577
  add_section(lines, "Code Context", report_error: true) do
578
- compose(GetEditContext, file: file, near: parsed[:method_name] || line.to_s)
578
+ compose(GetEditContext, file: file, near: parsed[:method_name], line: line)
579
579
  end
580
580
  end
581
581
 
@@ -616,12 +616,18 @@ module RailsAiContext
616
616
 
617
617
  # Regex because a lone source line is no program a listener can walk.
618
618
  def nil_receiver(file, line, method)
619
- call = /((?:@@?|\$)?\w+)\.#{Regexp.escape(method)}(?![\w?!])/
619
+ name = method.chomp("=")
620
+ # A `%w[` literal is no index call, and in `a[:b][:c]` the nil one may be `a[:b]`.
621
+ call = if method.start_with?("[") then /(?<![%\w@$])((?:@@?|\$)?\w+)\[[^\[\]]*\](?!\[)/
622
+ elsif method.end_with?("=") then /((?:@@?|\$)?\w+)\.#{Regexp.escape(name)}\s*=(?!=)/
623
+ else /((?:@@?|\$)?\w+)\.#{Regexp.escape(method)}(?![\w?!])/
624
+ end
620
625
  located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
621
626
  failing = line.to_i.positive? && located.ok? && RailsAiContext::SafeFile.read(located.realpath).to_s.lines[line.to_i - 1]
622
627
  return failing[call, 1] if failing
628
+ return nil if method.start_with?("[")
623
629
 
624
- response_text(compose(GetEditContext, file: file, near: method))[call, 1]
630
+ response_text(compose(GetEditContext, file: file, near: ".#{name}"))[call, 1]
625
631
  end
626
632
 
627
633
  # Infer a specific diagnosis from the error + context
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  tool_name "rails_get_edit_context"
7
7
  description "Get targeted code context for surgical edits: returns matching lines with surrounding code and line numbers. " \
8
8
  "Use when: you need to edit a specific method or section without reading the entire file. " \
9
- "Requires file:\"app/models/user.rb\" and near:\"def activate\" to locate the code region."
9
+ "Requires file:\"app/models/user.rb\" and near:\"def activate\" (or line:42) to locate the code region."
10
10
 
11
11
  input_schema(
12
12
  properties: {
@@ -21,9 +21,13 @@ module RailsAiContext
21
21
  context_lines: {
22
22
  type: "integer",
23
23
  description: "Lines of context above and below the match. Default: 5."
24
+ },
25
+ line: {
26
+ type: "integer",
27
+ description: "Show context_lines either side of this line number instead of searching for `near` (e.g. a line from a stack trace)."
24
28
  }
25
29
  },
26
- required: %w[file near]
30
+ required: %w[file]
27
31
  )
28
32
 
29
33
  guide_row(
@@ -35,14 +39,14 @@ module RailsAiContext
35
39
 
36
40
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
37
41
 
38
- def self.call(file:, near:, context_lines: 5, server_context: nil)
42
+ def self.call(file:, near: nil, context_lines: 5, line: nil, server_context: nil)
39
43
  # Reject empty parameters
40
44
  if file.nil? || file.strip.empty?
41
45
  return text_response("The `file` parameter is required. Provide a path relative to Rails root (e.g. 'app/models/post.rb').")
42
46
  end
43
47
 
44
- if near.nil? || near.strip.empty?
45
- return text_response("The `near` parameter is required. Provide a method name, keyword, or string to find.")
48
+ if line.nil? && (near.nil? || near.strip.empty?)
49
+ return text_response("The `near` or `line` parameter is required. Provide a method name, keyword, or string to find, or a line number.")
46
50
  end
47
51
 
48
52
  located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
@@ -79,10 +83,13 @@ module RailsAiContext
79
83
  # start brought back a window where a wrong guess brought "not found".
80
84
  redacted = RailsAiContext::Redaction.redact_source_lines(source_lines.map(&:rstrip), path: file)
81
85
 
82
- # Find all matching lines
83
- matches = []
84
- redacted.each_with_index do |line, idx|
85
- matches << idx if line.include?(near)
86
+ at_line = line.to_i.between?(1, source_lines.size)
87
+ matches = if at_line
88
+ [ line.to_i - 1 ]
89
+ elsif near.nil? || near.strip.empty?
90
+ return empty_response("Line #{echo_input(line.to_s)} is not in #{file} (#{count_phrase(source_lines.size, "line")}).")
91
+ else
92
+ redacted.each_index.select { |idx| redacted[idx].include?(near) }
86
93
  end
87
94
 
88
95
  if matches.empty?
@@ -94,9 +101,10 @@ module RailsAiContext
94
101
  start_idx = [ match_idx - context_lines, 0 ].max
95
102
  end_idx = [ match_idx + context_lines, source_lines.size - 1 ].min
96
103
 
97
- # If match is inside a method, expand to include the full method
104
+ # If match is inside a method, expand to include the full method; a line asked for keeps its
105
+ # window, which a 300-line method would push out of a caller's cut.
98
106
  method_start = find_method_start(source_lines, match_idx)
99
- method_end = find_method_end(source_lines, method_start) if method_start
107
+ method_end = find_method_end(source_lines, method_start) if method_start && !at_line
100
108
  if method_start && method_end && source_lines[match_idx].match?(/\A\s*def\s/)
101
109
  # Match IS a method definition - show the complete method, no extra context
102
110
  start_idx = method_start
@@ -148,10 +148,7 @@ module RailsAiContext
148
148
  lines.concat(column_mention_lines(column)) if %w[remove_column rename_column change_type].include?(action) && column
149
149
 
150
150
  # Strong Migrations warnings (only when the gem is present in the project).
151
- # add_association accepts the associated table via either `column` or
152
- # `type` (see generate_add_association) - resolve the same way here.
153
- warning_column = action == "add_association" ? (column || type) : column
154
- lines.concat(strong_migrations_warnings(action, table, warning_column, options)) if strong_migrations_gem_present?
151
+ lines.concat(strong_migrations_warnings(action, table, column, options)) if strong_migrations_gem_present?
155
152
 
156
153
  text_response(lines.join("\n"))
157
154
  end
@@ -532,7 +529,8 @@ module RailsAiContext
532
529
  reference = written.to_s.singularize
533
530
  lines = []
534
531
  fk_column = "#{reference}_id"
535
- if column_exists?(table, fk_column)
532
+ fk_column_exists = column_exists?(table, fk_column)
533
+ if fk_column_exists
536
534
  lines << "**Warning:** Column `#{fk_column}` already exists on `#{table}`. This migration will fail. Use `add_index` if you only need an index."
537
535
  lines << ""
538
536
  end
@@ -563,20 +561,76 @@ module RailsAiContext
563
561
  lines << ""
564
562
  end
565
563
 
566
- foreign_key = if across || to_table.nil? then ""
567
- elsif to_table == reference.pluralize then ", foreign_key: true"
568
- else ", foreign_key: { to_table: :#{to_table} }"
564
+ composite = to_table && !across && composite_key(schema, to_table)
565
+ if composite
566
+ lines << "**Foreign key:** `#{to_table}` has the composite primary key (#{composite.join(', ')}), " \
567
+ "which one column cannot reference, so the reference below has none."
568
+ lines << ""
569
569
  end
570
+
571
+ fk_target = to_table unless across || composite
572
+ foreign_key = fk_target ? foreign_key_option(schema, fk_target, reference) : ""
573
+ reference_call = "add_reference :#{table}, :#{reference}#{key_type_option(schema, table, to_table)}"
570
574
  lines << "```ruby"
571
575
  lines << "class Add#{written.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
572
576
  lines << " def change"
573
- lines << " add_reference :#{table}, :#{reference}#{key_type_option(schema, to_table)}#{foreign_key}"
577
+ lines << " #{reference_call}#{foreign_key}"
574
578
  lines << " end"
575
579
  lines << "end"
576
580
  lines << "```"
577
581
  lines << ""
578
582
  lines << "**Reversible:** Yes"
579
583
  lines.concat(association_model_lines(table, reference, to_table))
584
+ # A migration that fails on its existing column has nothing to make safe.
585
+ lines.concat(association_strong_migrations_lines(table, reference, reference_call, fk_target, schema)) if strong_migrations_gem_present? && !fk_column_exists
586
+ lines
587
+ end
588
+
589
+ # The add_reference above as strong_migrations wants it on each
590
+ # database: the index and the foreign key are what it checks.
591
+ def association_strong_migrations_lines(table, reference, reference_call, fk_target, schema)
592
+ column_option = ", column: :#{reference}_id"
593
+ target_key = custom_primary_key(schema, fk_target) if fk_target
594
+ add_foreign_key_call = "add_foreign_key :#{table}, :#{fk_target}#{column_option}#{", primary_key: :#{target_key}" if target_key}"
595
+ warnings = case family(table).key
596
+ when :postgresql
597
+ # strong_migrations before 2.5.2 flags any key inside add_reference, so the key goes on its own.
598
+ unvalidated_key_step = " and `#{add_foreign_key_call}, validate: false`" if fk_target
599
+ steps = [
600
+ "**`add_reference` builds its index without `algorithm: :concurrently`**, which blocks writes" \
601
+ "#{", and its foreign key checks every existing row under a `SHARE ROW EXCLUSIVE` lock on both tables" if fk_target}. Instead:",
602
+ " 1. `disable_ddl_transaction!` at the top of the migration, then `#{reference_call}, index: { algorithm: :concurrently }`#{unvalidated_key_step}"
603
+ ]
604
+ steps << " 2. In a separate migration: `validate_foreign_key :#{table}, :#{fk_target}#{column_option}`" if fk_target
605
+ steps
606
+ when :mysql
607
+ if fk_target && strong_migrations_checks_mysql_foreign_keys?
608
+ [
609
+ "**`add_reference` with a foreign key blocks writes on both tables.** Add the reference without one, `#{reference_call}`, " \
610
+ "and the key in a separate migration. If every row is valid and migrations do not run through a connection pooler:",
611
+ "```ruby",
612
+ "class Add#{reference.camelize}ForeignKeyTo#{table.camelize} < ActiveRecord::Migration[#{rails_version}]",
613
+ " def up",
614
+ " safety_assured do",
615
+ " execute \"SET SESSION foreign_key_checks = 0\"",
616
+ " #{add_foreign_key_call}",
617
+ " ensure",
618
+ " execute \"SET SESSION foreign_key_checks = 1\"",
619
+ " end",
620
+ " end",
621
+ "",
622
+ " def down",
623
+ " remove_foreign_key :#{table}#{column_option}",
624
+ " end",
625
+ "end",
626
+ "```"
627
+ ]
628
+ end
629
+ when :sqlite then nil # strong_migrations checks no foreign key on SQLite
630
+ else
631
+ [ "**The foreign key from `add_reference` validates existing rows**, which can block writes on both tables while it runs." ] if fk_target
632
+ end
633
+ strong_migrations_section(warnings)
580
634
  end
581
635
 
582
636
  # The belongs_to and has_many the reference wants, in the models that
@@ -617,18 +671,53 @@ module RailsAiContext
617
671
  Payload.schema_tables(schema).any? { |_, table, _| table.to_s == name.to_s }
618
672
  end
619
673
 
620
- # The primary key type of a table when it is not the integer Rails defaults to.
621
- def custom_key_type(table_data)
674
+ def key_column(table_data)
622
675
  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)
676
+ keys.first if keys.size == 1
625
677
  end
626
678
 
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)
679
+ # The key type of a table when a reference would not get it by default: bigint never, integer unless counted as default.
680
+ def custom_key_type(table_data, integer_is_default: true)
681
+ type = key_column(table_data)&.dig(:type)&.to_s
682
+ type unless type.nil? || type == "bigint" || (integer_is_default && type == "integer")
683
+ end
684
+
685
+ # A reference takes the column type of a serial key, never a sequence of its own.
686
+ SERIAL_COLUMN_TYPES = { "serial" => "integer", "smallserial" => "smallint", "bigserial" => nil }.freeze
687
+
688
+ # The type, limit and sign a reference needs to match its key, which a MySQL foreign key requires;
689
+ # only SQLite types every integer alike. A table the schema lacks is keyed as the app's tables are.
690
+ def key_type_option(schema, table, to_table)
691
+ database = family(table).key
629
692
  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}" : ""
693
+ key = key_column(target) if target
694
+ type = target ? custom_key_type(target, integer_is_default: database == :sqlite) : app_key_type(schema)
695
+ type = SERIAL_COLUMN_TYPES.fetch(type, type)
696
+ limit = key[:limit] if key && type == "integer"
697
+ unsigned = key && key[:unsigned] && database == :mysql
698
+ "#{", type: :#{type}" if type}#{", limit: #{limit}" if limit}#{", unsigned: true" if unsigned}"
699
+ end
700
+
701
+ # Rails points a foreign key at the reference pluralized and at its
702
+ # `id`, so another table, or a table keyed by another column, is named.
703
+ def foreign_key_option(schema, to_table, reference)
704
+ key = custom_primary_key(schema, to_table)
705
+ options = [ ("to_table: :#{to_table}" if to_table != reference.pluralize), ("primary_key: :#{key}" if key) ].compact
706
+ options.empty? ? ", foreign_key: true" : ", foreign_key: { #{options.join(', ')} }"
707
+ end
708
+
709
+ def custom_primary_key(schema, to_table)
710
+ key = primary_key(schema, to_table)
711
+ key if key.is_a?(String) && key != "id"
712
+ end
713
+
714
+ def composite_key(schema, to_table)
715
+ key = primary_key(schema, to_table)
716
+ key if key.is_a?(Array)
717
+ end
718
+
719
+ def primary_key(schema, to_table)
720
+ schema_known?(schema) ? Payload.schema_table(schema, to_table)&.dig(:primary_key) : nil
632
721
  end
633
722
 
634
723
  # The key type more than half of the app's tables share, when it is not Rails' default.
@@ -698,7 +787,14 @@ module RailsAiContext
698
787
  name = parts[0]&.strip
699
788
  type = parts[1]&.strip || "string"
700
789
  if type == "references"
701
- lines << " t.references :#{name}#{key_type_option(schema, name.to_s.pluralize)}, foreign_key: true"
790
+ to_table = name.to_s.pluralize
791
+ missing = schema_known?(schema) && !table_in_schema?(schema, to_table)
792
+ composite = !missing && composite_key(schema, to_table)
793
+ foreign_key = if missing then " # no foreign key: the schema has no #{to_table} table"
794
+ elsif composite then " # no foreign key: #{to_table} has the composite primary key (#{composite.join(', ')})"
795
+ else foreign_key_option(schema, to_table, name)
796
+ end
797
+ lines << " t.references :#{name}#{key_type_option(schema, table, to_table)}#{foreign_key}"
702
798
  else
703
799
  lines << " t.#{type} :#{name}"
704
800
  end
@@ -719,7 +815,8 @@ module RailsAiContext
719
815
  # at migration runtime, so AI agents see them at code-generation time. Only fires
720
816
  # when the gem is actually present in the project's Gemfile.lock.
721
817
  #
722
- # Catalog covers the most common breaking-change patterns (columns, indexes, FKs).
818
+ # Catalog covers the most common breaking-change patterns (columns, indexes);
819
+ # add_association's warning lives with the reference it rewrites.
723
820
  # Not exhaustive - see https://github.com/ankane/strong_migrations#checks for the full list.
724
821
  def strong_migrations_warnings(action, table, column, options)
725
822
  warnings = case action
@@ -751,26 +848,6 @@ module RailsAiContext
751
848
  "Use `add_index :#{table}, :#{column}, algorithm: :concurrently` and add `disable_ddl_transaction!` at the top of the migration."
752
849
  ]
753
850
  end
754
- when "add_association"
755
- case family(table).key
756
- when :mysql
757
- [
758
- "**`add_foreign_key` blocks writes on both tables while validating existing rows.** MySQL/MariaDB has no `validate: false` + separate-validation two-step like Postgres.",
759
- "If you're certain all rows are valid, wrap in `safety_assured` and drop `foreign_key_checks` for the duration:",
760
- " 1. `execute \"SET SESSION foreign_key_checks = 0\"`",
761
- " 2. `add_foreign_key :#{table}, :#{column}`",
762
- " 3. `execute \"SET SESSION foreign_key_checks = 1\"`"
763
- ]
764
- when :postgresql
765
- [
766
- "**`add_foreign_key` validates existing rows by default**, which acquires a `SHARE ROW EXCLUSIVE` lock on both tables.",
767
- "Safer two-step pattern:",
768
- " 1. `add_foreign_key :#{table}, :#{column}, validate: false` (lock-free)",
769
- " 2. In a separate migration: `validate_foreign_key :#{table}, :#{column}`"
770
- ]
771
- else
772
- [ "**`add_foreign_key` validates existing rows by default**, which can block writes on both tables while it runs." ]
773
- end
774
851
  when "add_column"
775
852
  if options.to_s.match?(/null:\s*false/) && !options.to_s.include?("default:")
776
853
  [
@@ -784,6 +861,10 @@ module RailsAiContext
784
861
  end
785
862
  end
786
863
 
864
+ strong_migrations_section(warnings)
865
+ end
866
+
867
+ def strong_migrations_section(warnings)
787
868
  return [] unless warnings&.any?
788
869
 
789
870
  [ "", "## Strong Migrations Warnings", "", "_The `strong_migrations` gem is in your Gemfile - these warnings match what it would raise at migration runtime._", "" ] + warnings
@@ -795,6 +876,14 @@ module RailsAiContext
795
876
  RailsAiContext.debug_fail(e, false, label: "strong_migrations_gem_present?")
796
877
  end
797
878
 
879
+ # strong_migrations checks a MySQL foreign key from 2.7 on; an unlocked or unreadable version is taken as current.
880
+ def strong_migrations_checks_mysql_foreign_keys?
881
+ version = RailsAiContext::GemLock.for(rails_app.root).version("strong_migrations")
882
+ version.nil? || Gem::Version.new(version).release >= Gem::Version.new("2.7")
883
+ rescue => e
884
+ RailsAiContext.debug_fail(e, true, label: "strong_migrations_checks_mysql_foreign_keys?")
885
+ end
886
+
798
887
  # ignored_columns has to go on the class that owns the table, and
799
888
  # every STI class records that same table, so a child that sorts
800
889
  # earlier would otherwise win. The conventional name is the base.
@@ -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
@@ -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.3"
4
+ VERSION = "5.36.5"
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.3
4
+ version: 5.36.5
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