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