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 +4 -4
- data/CHANGELOG.md +81 -0
- data/SECURITY.md +1 -1
- data/docs/COMPATIBILITY.md +14 -0
- data/docs/CONFIGURATION.md +1 -0
- data/docs/GUIDE.md +2 -1
- data/docs/SECURITY.md +1 -1
- data/docs/STANDALONE.md +20 -0
- data/docs/TOOLS.md +2 -1
- data/exe/rails-ai-context +28 -1
- data/lib/rails_ai_context/ast_cache.rb +5 -3
- data/lib/rails_ai_context/fingerprinter.rb +1 -1
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +4 -2
- data/lib/rails_ai_context/introspectors/node_source.rb +9 -4
- data/lib/rails_ai_context/server.rb +9 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +41 -57
- data/lib/rails_ai_context/tools/diagnose.rb +12 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +19 -11
- data/lib/rails_ai_context/tools/migration_advisor.rb +128 -39
- data/lib/rails_ai_context/tools/onboard.rb +2 -1
- data/lib/rails_ai_context/update_check.rb +149 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d19c2da6b45c1d8622c9d97bf31f005a10a904c1c6361c9f2d4d75819bdb236d
|
|
4
|
+
data.tar.gz: cdc5979f3619e5823f7a3b158864e4c0fe1b2e62037074925334dcfbef50ce2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -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
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" =>
|
|
178
|
-
"app/javascript/controllers" =>
|
|
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
|
|
|
@@ -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?(
|
|
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.
|
|
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.
|
|
26
|
-
"#{node.slice}\n#{body_start(first).join(last
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"
|
|
280
|
-
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
|
69
|
+
discover_views(ctx, root, pattern, lines)
|
|
74
70
|
discover_stimulus(ctx, pattern, lines)
|
|
75
|
-
|
|
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
|
|
82
|
-
discover_test_gaps(root, pattern, matched_models, ctx, all_tests
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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}#{
|
|
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
|
|
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
|
-
|
|
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}#{
|
|
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
|
|
455
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
470
|
+
return all if found.empty?
|
|
485
471
|
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
482
|
+
all
|
|
498
483
|
rescue => e
|
|
499
|
-
RailsAiContext.debug_fail(e, [
|
|
484
|
+
RailsAiContext.debug_fail(e, [], label: "discover_tests")
|
|
500
485
|
end
|
|
501
486
|
|
|
502
487
|
# --- Test coverage gaps ---
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
678
|
-
|
|
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] ||
|
|
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(/[
|
|
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]
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
83
|
-
matches =
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 << "
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
type unless type.nil? || %w[bigint integer].include?(type)
|
|
676
|
+
keys.first if keys.size == 1
|
|
625
677
|
end
|
|
626
678
|
|
|
627
|
-
#
|
|
628
|
-
def
|
|
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
|
-
|
|
631
|
-
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
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.
|
|
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
|