rails-ai-context 5.36.2 → 5.36.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/docs/COMPATIBILITY.md +7 -0
- data/docs/INTROSPECTORS.md +1 -1
- data/docs/TOOLS.md +1 -1
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +26 -1
- 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/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/review_changes.rb +8 -2
- data/lib/rails_ai_context/tools/search_code.rb +9 -2
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecc8cfb0b751983a682c2a2c0bd2082216595b83f12f679d190506425d60ae48
|
|
4
|
+
data.tar.gz: e0674fed2e5f40ebe3f33166c59d9c17bcd5c5160352100cf8b38d87a73387a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c78d50c7e050632c88f60e901a8805ed5d397f2f12fb9412fa7ac64af1ed3921ff776486a8e4912f0a5a531e6a90661ca6d7b35def872bc4cd11215bde2941b0
|
|
7
|
+
data.tar.gz: 0b597f6cc7372007cb84936ac62f2ca893791622c62a667ea4601b545c73a9719949f3abfc09fd8fe75fe2e143fb1bfa5a4bbf82ee47e4f5fca6d293392c5b9d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,55 @@ 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.3] - 2026-10-15
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`rails_diagnose` names an instance variable receiver with its `@`, and takes it from the line the error names.**
|
|
13
|
+
`@widget.name` read as `widget`, so the advice named a variable the action does
|
|
14
|
+
not have. The receiver also came from the first call of the method in the file,
|
|
15
|
+
not from the line. A line that does not call the method on a plain name now
|
|
16
|
+
gets no receiver instead of another line's. 5.36.2 and earlier versions had the
|
|
17
|
+
gap.
|
|
18
|
+
- **`rails_generate_test` says which sign-in helper names it looked for.** The
|
|
19
|
+
TODO read "this app's tests define no sign-in helper" for a suite that signs in
|
|
20
|
+
through a helper with another name. It now reads "no `sign_in` or `log_in`
|
|
21
|
+
helper in this app's test helpers".
|
|
22
|
+
- **A `change_table` migration lists what its block does.** `rails://migrations`
|
|
23
|
+
showed `"actions": []` for a migration that adds columns through
|
|
24
|
+
`change_table`, and `migration_stats` left it out of `total_add_column`. The
|
|
25
|
+
table calls in the block (`t.string`, `t.references`, `t.index`, `t.remove` and
|
|
26
|
+
the others) are read from the AST. A `create_table` block's columns are not
|
|
27
|
+
counted.
|
|
28
|
+
- **`rails_search_code` trace says how many sibling methods and internal calls it
|
|
29
|
+
left out.** It listed 10 siblings and 15 internal calls and stopped. The list
|
|
30
|
+
now ends with "_...and N more_".
|
|
31
|
+
- **`rails_get_job_pattern` reads sidekiq-scheduler entries from every
|
|
32
|
+
`config/sidekiq*.yml`.** A schedule kept in `config/sidekiq_production.yml`,
|
|
33
|
+
one file per environment, showed no `Schedule`, so a worker that runs every
|
|
34
|
+
night read like dead code. Each file's entry is shown with its file name.
|
|
35
|
+
- **`rails_get_config(section:"conventions")` counts the rake tasks of
|
|
36
|
+
`lib/tasks` and the templates of `app/views`.** The directory counts took
|
|
37
|
+
`.rb` files only: `lib/tasks` read 2 files for 67, and `app/views` never
|
|
38
|
+
appeared.
|
|
39
|
+
- **`rails_get_test_info` counts only `*_spec.rb` under `spec/` in an RSpec
|
|
40
|
+
suite.** A `*_test.rb` helper in `spec/support` was counted as a test file. A
|
|
41
|
+
Minitest suite still counts both `*_spec.rb` and `*_test.rb`.
|
|
42
|
+
- **`rails_migration_advisor` writes a reference and a new table with the key
|
|
43
|
+
type the app uses.** On a schema of `id: :uuid` tables, `add_reference` made a
|
|
44
|
+
bigint column that PostgreSQL refuses to key to a uuid, and `create_table`
|
|
45
|
+
made the one bigint table. `add_reference` and `t.references` take the key type
|
|
46
|
+
of the table they point at (the app's type when the schema lacks that table),
|
|
47
|
+
and `create_table` takes the type most of the app's tables share.
|
|
48
|
+
- **`rails_review_changes` matches `files` by whole path or directory.**
|
|
49
|
+
`files: ["Gemfile"]` also returned `Gemfile.lock`. A directory such as
|
|
50
|
+
`app/models` still matches the files under it.
|
|
51
|
+
- **`rails_analyze_feature` says which directories it stopped reading.** It reads
|
|
52
|
+
the first 500 files of a directory, and a service, view or test past that was
|
|
53
|
+
missed with no word. The answer now ends with the directories it did not
|
|
54
|
+
finish. The "first 500 scanned" note no longer shows when exactly 500 files were
|
|
55
|
+
read.
|
|
56
|
+
|
|
8
57
|
## [5.36.2] - 2026-10-15
|
|
9
58
|
|
|
10
59
|
### Fixed
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -333,6 +333,13 @@ 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.
|
|
336
343
|
2. Non-crash coverage for every built-in tool including `get_view` in
|
|
337
344
|
`spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
|
|
338
345
|
(ivar cross-check, render-form detection, partial interfaces) verified
|
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/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
|
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -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 << ""
|