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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a04632cfd1114032ce4434e7e8b99d5f92c81bc9537bf5c65b6c65a3124c278
4
- data.tar.gz: 3c5210bcb7ba0fef976a00b1224647588d44556bb8d807cbbc87aca9ff0214ce
3
+ metadata.gz: ecc8cfb0b751983a682c2a2c0bd2082216595b83f12f679d190506425d60ae48
4
+ data.tar.gz: e0674fed2e5f40ebe3f33166c59d9c17bcd5c5160352100cf8b38d87a73387a4
5
5
  SHA512:
6
- metadata.gz: 41e3246dc1905379a9d39bc4bfb8e95498b752a95f1a96b2e955ad58ca22a06696d0c12fd1b676e7414086cea5e571f772c8638dec36823ee542462dca8f4e64
7
- data.tar.gz: ea9ebf310554c650503a9f9b95521988eccbf42286885b294ae86031d8c393a3dc71161ecab3852bb69a17055167b5334669fd0187817c62c73f98ecbf0843c1
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
@@ -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
@@ -285,7 +285,7 @@ same wherever it is asked. Those live as their own modules under
285
285
  | `ModuleAliases` | Which app file a bare JS import specifier names: tsconfig/jsconfig `compilerOptions.paths` followed through `extends` (relative files and installed packages), and a vite/webpack/rspack `resolve.alias` written as a literal object. The Stimulus scan uses it to tie a registration or a base class to the controller file it imports |
286
286
  | `HelperNames` | The helper methods a view can call: every method the app's helper modules define in every code root, and those of a module they `include`, found through the app's autoload roots (`lib` among them) or in its enclosing namespace's file (`CanonicalURL::Helpers` in `canonical_url.rb`). `rails_get_partial_interface` uses it so a helper call is not read as a local |
287
287
  | `Interaction` | Whether a class runs as an ActiveInteraction, following its superclass chain through the app's own sources, and the filters it takes - inherited ones first, one per name, each carrying the filters nested inside its block. See the **Interaction filter** entry in `CONTEXT.md` |
288
- | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of `config/sidekiq.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
288
+ | `RecurringSchedules` | The recurring tasks each scheduler reads from its own file: Solid Queue's `config/recurring.yml`, sidekiq-cron's `config/schedule.yml`, sidekiq-scheduler's section of each `config/sidekiq*.yml`, GoodJob's `config.good_job.cron` and whenever's `config/schedule.rb`. The job introspector hands it its own config walk, so a file it also reads for queue or mailer settings is walked once |
289
289
  | `AdminResources` | The models an admin gem exposes (ActiveAdmin, Trestle, Administrate, Avo, Madmin), from the folder each gem's generator writes to, with ActiveAdmin's `permit_params`. `rails_get_config(section:"conventions")` and `rails_analyze_feature` both ask it |
290
290
  | `SchemaDumpPath` | The primary database's dump file as `DatabaseTasks.schema_dump_path` names it: `database.yml`'s `schema_dump` in the configured `schema_format`, then each format's default file, and the secondary databases' dumps. Every schema reader, the doctor and the pending-migration check ask it |
291
291
  | `ApartmentConfig` | The models an Apartment initializer keeps in the shared schema (`excluded_models`), or the source of a list the file computes. The model introspector asks it to say which models get one table per tenant |
data/docs/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
- count += Dir.glob(File.join(root, dir, "**/*.js")).size if dir.include?("javascript")
201
+ glob = NON_RUBY_FILES[dir]
202
+ count += PathResolver.dirs_for(root, dir).sum { |path| Dir.glob(File.join(path, glob)).size } if glob
194
203
 
195
204
  hash[dir] = count if count > 0
196
205
  end
@@ -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
- %w[config/schedule.yml config/schedule.yaml config/sidekiq_cron.yml].flat_map do |file|
32
+ SIDEKIQ_CRON_FILES.flat_map do |file|
31
33
  named_tasks(yaml(root, file)).filter_map { |name, options| task(file, name, options, :cron) }
32
34
  end
33
35
  end
34
36
 
35
37
  # sidekiq-scheduler runs a job by its entry's name when no class is given.
38
+ # Sidekiq starts from any file `-C` names, and apps keep one per environment.
36
39
  def sidekiq_scheduler(root)
37
- file = "config/sidekiq.yml"
38
- data = yaml(root, file)
39
- return [] unless data.is_a?(Hash)
40
+ (Dir.glob("config/sidekiq*.{yml,yaml}", base: root.to_s).sort - SIDEKIQ_CRON_FILES).flat_map do |file|
41
+ data = yaml(root, file)
42
+ next [] unless data.is_a?(Hash)
40
43
 
41
- section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
42
- named_tasks(section).filter_map do |name, options|
43
- next unless options.is_a?(Hash)
44
+ section = data["scheduler"].is_a?(Hash) ? data["scheduler"]["schedule"] : data["schedule"]
45
+ named_tasks(section).filter_map do |name, options|
46
+ next unless options.is_a?(Hash)
44
47
 
45
- kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
46
- task(file, name, { "class" => name }.merge(options), kind)
48
+ kind = SCHEDULER_KEYS.find { |key| options.key?(key.to_s) } or next
49
+ task(file, name, { "class" => name }.merge(options), kind)
50
+ end
47
51
  end
48
52
  end
49
53
 
@@ -10,8 +10,6 @@ module RailsAiContext
10
10
  extend StaticTier
11
11
  static_tier :files_only
12
12
 
13
- TEST_FILE_GLOB = "*_{spec,test}.rb"
14
-
15
13
  # An engine's test/dummy is tested by the engine's suite.
16
14
  def suite_root
17
15
  @suite_root ||= PathResolver.test_root(root)
@@ -56,6 +54,17 @@ module RailsAiContext
56
54
  RailsAiContext.debug_fail(e, "unknown", label: "detect_framework")
57
55
  end
58
56
 
57
+ # RSpec runs *_spec.rb only, so a *_test.rb under spec/ is a helper.
58
+ def test_file_glob(base)
59
+ base == "spec" && rspec_suite? ? "*_spec.rb" : "*_{spec,test}.rb"
60
+ end
61
+
62
+ def rspec_suite?
63
+ return @rspec_suite if defined?(@rspec_suite)
64
+
65
+ @rspec_suite = RailsAiContext::TestFramework.suites(suite_root).include?("rspec")
66
+ end
67
+
59
68
  # First listed wins: an app holding the same thing under spec/ and test/
60
69
  # reports one location, not both. `detect_system_tests` deliberately
61
70
  # answers the other way.
@@ -247,7 +256,7 @@ module RailsAiContext
247
256
  dir = File.join(suite_root, rel)
248
257
  next unless Dir.exist?(dir)
249
258
 
250
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
259
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(rel.split("/").first)}")).size
251
260
  [ rel, count ] if count > 0
252
261
  end
253
262
  return nil if rows.empty?
@@ -479,11 +488,11 @@ module RailsAiContext
479
488
  dir = File.join(base_dir, entry)
480
489
  next unless File.directory?(dir)
481
490
 
482
- count = Dir.glob(File.join(dir, "**/#{TEST_FILE_GLOB}")).size
491
+ count = Dir.glob(File.join(dir, "**/#{test_file_glob(base)}")).size
483
492
  rows[entry] << [ "#{base}/#{entry}", count ] if count > 0
484
493
  end
485
494
 
486
- loose = Dir.glob(File.join(base_dir, TEST_FILE_GLOB)).size
495
+ loose = Dir.glob(File.join(base_dir, test_file_glob(base))).size
487
496
  rows[base] << [ base, loose ] if loose > 0
488
497
  end
489
498
 
@@ -62,23 +62,24 @@ module RailsAiContext
62
62
  pattern = feature.downcase
63
63
  root = rails_app.root.to_s
64
64
  lines = [ "# Feature Analysis: #{feature}", "" ]
65
+ unread = Set.new
65
66
 
66
67
  matched_models = discover_models(ctx, pattern, lines)
67
68
  discover_controllers(ctx, pattern, lines)
68
69
  discover_routes(ctx, pattern, lines)
69
- discover_services(root, pattern, lines)
70
+ discover_services(root, pattern, lines, unread)
70
71
  discover_admin(root, pattern, lines)
71
72
  discover_jobs(ctx, pattern, lines)
72
- discover_views(ctx, root, pattern, lines)
73
+ discover_views(ctx, root, pattern, lines, unread)
73
74
  discover_stimulus(ctx, pattern, lines)
74
- _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines)
75
+ _feature_tests, all_tests, tests_truncated = discover_tests(root, pattern, lines, unread)
75
76
  discover_related_models(ctx, matched_models, lines)
76
77
  discover_concerns(ctx, matched_models, lines)
77
78
  discover_callbacks(ctx, matched_models, lines)
78
79
  discover_channels(ctx, pattern, lines)
79
80
  discover_mailers(ctx, pattern, lines)
80
- discover_env_dependencies(root, pattern, matched_models, lines)
81
- discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, truncated: tests_truncated)
81
+ discover_env_dependencies(root, pattern, matched_models, lines, unread)
82
+ discover_test_gaps(root, pattern, matched_models, ctx, all_tests || [], lines, unread, truncated: tests_truncated)
82
83
  discover_components(ctx, pattern, lines)
83
84
 
84
85
  # For auth-related keywords, also discover auth gems
@@ -96,10 +97,17 @@ module RailsAiContext
96
97
  # If nothing was discovered, return a clean "no match" with real suggestions
97
98
  has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
98
99
  unless has_content
99
- return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}")
100
+ return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}#{unread_note(unread)}")
100
101
  end
101
102
 
102
- text_response(lines.join("\n"))
103
+ text_response(lines.join("\n") + unread_note(unread))
104
+ end
105
+
106
+ # Files past MAX_SCAN_FILES in a directory are never read, so a section that is missing may only be unread.
107
+ def self.unread_note(unread)
108
+ return "" if unread.empty?
109
+
110
+ "\n\n_Only the first #{MAX_SCAN_FILES} files of a directory are read, so any past them in #{unread.sort.join(', ')} were not checked._"
103
111
  end
104
112
 
105
113
  # The names a keyword that matched nothing could have been.
@@ -115,6 +123,13 @@ module RailsAiContext
115
123
  class << self
116
124
  private
117
125
 
126
+ # The first MAX_SCAN_FILES of a glob; a directory holding more goes into `unread`.
127
+ def capped_glob(dir, pattern, real_root, label, unread)
128
+ files = safe_glob(dir, pattern, real_root)
129
+ unread << label if files.size > MAX_SCAN_FILES
130
+ files.first(MAX_SCAN_FILES)
131
+ end
132
+
118
133
  # A conditional skip names the condition it is taken out on.
119
134
  def inherited_filter_label(filter)
120
135
  "#{filter[:name]}#{Serializers::SectionFacts.filter_origin(filter)}" \
@@ -260,13 +275,13 @@ module RailsAiContext
260
275
  end
261
276
 
262
277
  # --- AF1: Services ---
263
- def discover_services(root, pattern, lines)
278
+ def discover_services(root, pattern, lines, unread)
264
279
  dir = File.join(root, "app", "services")
265
280
  return unless Dir.exist?(dir)
266
281
 
267
282
  real_root = File.realpath(root).to_s
268
283
  real_dir = File.realpath(dir).to_s
269
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
284
+ candidates = capped_glob(dir, "**/*.rb", real_root, "app/services", unread)
270
285
  # Prefer basename match (fast, no file read) and only fall back to
271
286
  # a content scan for files whose basename doesn't match. This
272
287
  # avoids reading every service file's full contents on large apps.
@@ -287,7 +302,7 @@ module RailsAiContext
287
302
  named = services.select { |s| s[:name] }
288
303
  callers, callers_truncated = GetServicePattern.callers_of(named.to_h { |s| [ s[:name], s[:path] ] }, real_root)
289
304
 
290
- lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
305
+ lines << "## Services (#{found.size}#{unread.include?("app/services") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
291
306
  services.each do |service|
292
307
  relative = service[:path].sub("#{real_root}/", "")
293
308
  methods = service[:name] ? service_methods(service[:source], service[:name]) : []
@@ -353,19 +368,19 @@ module RailsAiContext
353
368
  end
354
369
 
355
370
  # --- AF3: Views + Partials ---
356
- def discover_views(ctx, root, pattern, lines)
371
+ def discover_views(ctx, root, pattern, lines, unread)
357
372
  views_dir = File.join(root, "app", "views")
358
373
  return unless Dir.exist?(views_dir)
359
374
 
360
375
  real_root = File.realpath(root).to_s
361
376
  real_views_dir = File.realpath(views_dir).to_s
362
- candidates = safe_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root).first(MAX_SCAN_FILES)
377
+ candidates = capped_glob(views_dir, "**/*.{erb,haml,slim,jbuilder}", real_root, "app/views", unread)
363
378
  found = candidates.select do |path|
364
379
  feature_word_match?(path.sub("#{real_views_dir}/", ""), pattern)
365
380
  end
366
381
  return if found.empty?
367
382
 
368
- lines << "## Views (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
383
+ lines << "## Views (#{found.size}#{unread.include?("app/views") ? " - first #{MAX_SCAN_FILES} scanned" : ""})"
369
384
  found.each do |path|
370
385
  relative = path.sub("#{real_views_dir}/", "")
371
386
  source = RailsAiContext::SafeFile.read(path) or next
@@ -439,7 +454,7 @@ module RailsAiContext
439
454
  # @return [Array(Array<String>, Array<String>, Boolean)] the tests the
440
455
  # feature word names, every test read (relative to the suite's root),
441
456
  # and whether a glob stopped at MAX_SCAN_FILES
442
- def discover_tests(root, pattern, lines)
457
+ def discover_tests(root, pattern, lines, unread)
443
458
  real_root = File.realpath(root).to_s
444
459
  found = []
445
460
  all = []
@@ -449,8 +464,7 @@ module RailsAiContext
449
464
  real_base = File.realpath(base).to_s
450
465
  [ File.join(base, "spec"), File.join(base, "test") ].each do |dir|
451
466
  next unless Dir.exist?(dir)
452
- suffix_glob = safe_glob(dir, "**/*_{test,spec}.rb", real_base).first(MAX_SCAN_FILES)
453
- truncated = true if suffix_glob.size == MAX_SCAN_FILES
467
+ suffix_glob = capped_glob(dir, "**/*_{test,spec}.rb", real_base, File.basename(dir), unread)
454
468
  # The path, not the basename: the gap checker below matches this
455
469
  # way for the same reason, and a spec whose feature word is a
456
470
  # directory (billing/invoices/create_spec.rb) is the normal shape
@@ -458,13 +472,13 @@ module RailsAiContext
458
472
  suffix_glob.each do |path|
459
473
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
460
474
  end
461
- prefix_glob = safe_glob(dir, "**/{test,spec}_*.rb", real_base).first(MAX_SCAN_FILES)
462
- truncated = true if prefix_glob.size == MAX_SCAN_FILES
475
+ prefix_glob = capped_glob(dir, "**/{test,spec}_*.rb", real_base, File.basename(dir), unread)
463
476
  prefix_glob.each do |path|
464
477
  found << path if feature_word_match?(relative_test_name(path, real_base), pattern)
465
478
  end
466
479
  all.concat(suffix_glob, prefix_glob)
467
480
  end
481
+ truncated = unread.include?("spec") || unread.include?("test")
468
482
  found.uniq!
469
483
  all = all.uniq.map { |path| path.delete_prefix("#{real_base}/") }
470
484
  return [ found, all, truncated ] if found.empty?
@@ -486,7 +500,7 @@ module RailsAiContext
486
500
  end
487
501
 
488
502
  # --- Test coverage gaps ---
489
- def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, truncated: false)
503
+ def discover_test_gaps(root, pattern, matched_models, ctx, test_files, lines, unread, truncated: false)
490
504
  # A gap is a claim that something has no test, and a scan that stopped
491
505
  # at MAX_SCAN_FILES cannot make it: Discourse has 1,672 spec files, so
492
506
  # the first 500 missed the ones that cover the feature and every
@@ -526,7 +540,7 @@ module RailsAiContext
526
540
  path_dir = File.join(root, "app", dir)
527
541
  next unless Dir.exist?(path_dir)
528
542
 
529
- safe_glob(path_dir, "**/*.rb", real_root).first(MAX_SCAN_FILES).each do |path|
543
+ capped_glob(path_dir, "**/*.rb", real_root, "app/#{dir}", unread).each do |path|
530
544
  snake = File.basename(path, ".rb")
531
545
  next unless feature_word_match?(snake, pattern)
532
546
  next if test_basenames.any? { |t| t.include?(snake) }
@@ -653,17 +667,14 @@ module RailsAiContext
653
667
  end
654
668
 
655
669
  # --- AF9: Environment Dependencies ---
656
- def discover_env_dependencies(root, pattern, matched_models, lines)
670
+ def discover_env_dependencies(root, pattern, matched_models, lines, unread)
657
671
  # Scan services, jobs, and model files for ENV references
658
672
  real_root = File.realpath(root).to_s
659
673
  dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
660
674
  env_vars = Set.new
661
- truncated = false
662
675
 
663
676
  dirs.each do |dir|
664
- candidates = safe_glob(dir, "**/*.rb", real_root).first(MAX_SCAN_FILES)
665
- truncated = true if candidates.size == MAX_SCAN_FILES
666
- candidates.each do |path|
677
+ capped_glob(dir, "**/*.rb", real_root, dir.delete_prefix("#{root}/"), unread).each do |path|
667
678
  next unless feature_word_match?(File.basename(path, ".rb"), pattern) || feature_word_match?(path, pattern)
668
679
  source = RailsAiContext::SafeFile.read(path) or next
669
680
  source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
@@ -673,6 +684,7 @@ module RailsAiContext
673
684
  end
674
685
  return if env_vars.empty?
675
686
 
687
+ truncated = dirs.any? { |dir| unread.include?(dir.delete_prefix("#{root}/")) }
676
688
  header = "## Environment Dependencies#{truncated ? " (first #{MAX_SCAN_FILES} per dir scanned)" : ""}"
677
689
  lines << header
678
690
  env_vars.sort.each { |v| lines << "- `#{v}`" }
@@ -186,7 +186,7 @@ module RailsAiContext
186
186
  # Likely cause - enriched with specific inference when possible
187
187
  lines << "## Likely Cause"
188
188
  lines << classification[:likely]
189
- specific = infer_specific_cause(parsed, classification, file, action)
189
+ specific = infer_specific_cause(parsed, classification, file, action, line)
190
190
  lines << "" << "**Specific:** #{specific}" if specific
191
191
  lines << ""
192
192
 
@@ -614,8 +614,18 @@ module RailsAiContext
614
614
  lines << "## #{title}" << "_Could not load: #{e.message}_" << ""
615
615
  end
616
616
 
617
+ # Regex because a lone source line is no program a listener can walk.
618
+ def nil_receiver(file, line, method)
619
+ call = /((?:@@?|\$)?\w+)\.#{Regexp.escape(method)}(?![\w?!])/
620
+ located = RailsAiContext::SafePath.locate(file, under: rails_app.root.to_s)
621
+ failing = line.to_i.positive? && located.ok? && RailsAiContext::SafeFile.read(located.realpath).to_s.lines[line.to_i - 1]
622
+ return failing[call, 1] if failing
623
+
624
+ response_text(compose(GetEditContext, file: file, near: method))[call, 1]
625
+ end
626
+
617
627
  # Infer a specific diagnosis from the error + context
618
- def infer_specific_cause(parsed, classification, file, action)
628
+ def infer_specific_cause(parsed, classification, file, action, line = nil)
619
629
  msg = parsed[:message].to_s
620
630
  method = parsed[:method_name]
621
631
 
@@ -629,12 +639,8 @@ module RailsAiContext
629
639
  # Check if calling on an association
630
640
  if method && file
631
641
  begin
632
- ctx = compose(GetEditContext, file: file, near: method)
633
- code = response_text(ctx)
634
- # Find the receiver: something.method_name
635
- receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
636
- if receiver_match
637
- receiver = receiver_match[1]
642
+ receiver = nil_receiver(file, line, method)
643
+ if receiver
638
644
  return "`#{receiver}` is nil when `.#{method}` is called. " \
639
645
  "This variable may not be set in all code paths - check if it's assigned before use, " \
640
646
  "or use `#{receiver}&.#{method}` for safe navigation."
@@ -793,7 +793,7 @@ module RailsAiContext
793
793
  names = res[:login].map { |login| login[:filter][:name] }.join(" and ")
794
794
  "this app's #{res[:helper]} is not known to get a request past #{names}, so send what #{names} checks"
795
795
  else
796
- "this app's tests define no sign-in helper, so sign a user in first the way the app's login does"
796
+ "no `sign_in` or `log_in` helper in this app's test helpers, so sign a user in first the way the app's login does"
797
797
  end
798
798
  [ " # TODO: #{login_phrase(ctrl_class, res)}, so each request here is redirected or refused until the test signs in: #{how}." ]
799
799
  end
@@ -570,7 +570,7 @@ module RailsAiContext
570
570
  lines << "```ruby"
571
571
  lines << "class Add#{written.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
572
572
  lines << " def change"
573
- lines << " add_reference :#{table}, :#{reference}#{foreign_key}"
573
+ lines << " add_reference :#{table}, :#{reference}#{key_type_option(schema, to_table)}#{foreign_key}"
574
574
  lines << " end"
575
575
  lines << "end"
576
576
  lines << "```"
@@ -617,6 +617,27 @@ module RailsAiContext
617
617
  Payload.schema_tables(schema).any? { |_, table, _| table.to_s == name.to_s }
618
618
  end
619
619
 
620
+ # The primary key type of a table when it is not the integer Rails defaults to.
621
+ def custom_key_type(table_data)
622
+ keys = Array(table_data && table_data[:columns]).select { |c| c[:primary_key] }
623
+ type = keys.first[:type].to_s if keys.size == 1
624
+ type unless type.nil? || %w[bigint integer].include?(type)
625
+ end
626
+
627
+ # ", type: :uuid" for a reference to a table keyed that way; a table the schema lacks is keyed as the app's tables are.
628
+ def key_type_option(schema, to_table)
629
+ target = to_table && schema_known?(schema) && Payload.schema_table(schema, to_table)
630
+ type = target ? custom_key_type(target) : app_key_type(schema)
631
+ type ? ", type: :#{type}" : ""
632
+ end
633
+
634
+ # The key type more than half of the app's tables share, when it is not Rails' default.
635
+ def app_key_type(schema)
636
+ tables = schema.is_a?(Hash) && schema[:tables].is_a?(Hash) ? schema[:tables].values : []
637
+ type, count = tables.map { |data| custom_key_type(data) }.tally.max_by(&:last)
638
+ type if type && count * 2 > tables.size
639
+ end
640
+
620
641
  def generate_change_type(table, column, type, options)
621
642
  return [ "**Error:** column and type are required" ] unless column && type
622
643
 
@@ -667,7 +688,9 @@ module RailsAiContext
667
688
  lines << "```ruby"
668
689
  lines << "class Create#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
669
690
  lines << " def change"
670
- lines << " create_table :#{table} do |t|"
691
+ schema = cached_context[:schema]
692
+ key_type = app_key_type(schema)
693
+ lines << " create_table :#{table}#{", id: :#{key_type}" if key_type} do |t|"
671
694
 
672
695
  if columns_str
673
696
  columns_str.split(",").each do |col|
@@ -675,7 +698,7 @@ module RailsAiContext
675
698
  name = parts[0]&.strip
676
699
  type = parts[1]&.strip || "string"
677
700
  if type == "references"
678
- lines << " t.references :#{name}, foreign_key: true"
701
+ lines << " t.references :#{name}#{key_type_option(schema, name.to_s.pluralize)}, foreign_key: true"
679
702
  else
680
703
  lines << " t.#{type} :#{name}"
681
704
  end
@@ -21,7 +21,7 @@ module RailsAiContext
21
21
  files: {
22
22
  type: "array",
23
23
  items: { type: "string" },
24
- description: "Filter to specific files (relative to Rails root). Omit to review all changed files."
24
+ description: "Filter to files or directories (relative to Rails root). Omit to review all changed files."
25
25
  }
26
26
  }
27
27
  )
@@ -91,7 +91,7 @@ module RailsAiContext
91
91
  end
92
92
 
93
93
  changed = get_changed_files(base, root)
94
- changed = changed.select { |f| files.any? { |filter| f.include?(filter) } } if files&.any?
94
+ changed = changed.select { |f| files.any? { |filter| path_matches?(f, filter) } } if files&.any?
95
95
 
96
96
  if changed.empty?
97
97
  return text_response("No changes found for ref '#{echo_input(ref)}'.#{files ? " Filter: #{echo_input(files.join(', '))}" : ""}")
@@ -214,6 +214,12 @@ module RailsAiContext
214
214
  status.success? && !prefix.strip.empty?
215
215
  end
216
216
 
217
+ # A filter names one file or a directory, never part of a name: `Gemfile` is not `Gemfile.lock`.
218
+ def path_matches?(path, filter)
219
+ wanted = filter.delete_prefix("./").chomp("/")
220
+ path == wanted || path.start_with?("#{wanted}/")
221
+ end
222
+
217
223
  # The Ruby folders count only Ruby files: the install writes app/models/AGENTS.md beside the models.
218
224
  def classify_file(path)
219
225
  case path
@@ -23,6 +23,11 @@ module RailsAiContext
23
23
  BINARY_PROBE_BYTES = 64 * 1024
24
24
 
25
25
  TEST_DIRS = %w[test/ spec/ features/].freeze
26
+
27
+ # A trace lists this many internal calls and sibling methods, then says how many it left out.
28
+ INTERNAL_CALLS_SHOWN = 15
29
+ SIBLINGS_SHOWN = 10
30
+
26
31
  MATCH_FIELD_SEPARATOR = "\x1f"
27
32
  # A comment line by the file's own syntax. `#{` and ` * ` are Ruby code;
28
33
  # `#id` is a selector in a stylesheet and an element in Haml or Slim.
@@ -768,7 +773,8 @@ module RailsAiContext
768
773
 
769
774
  if internal_calls.any?
770
775
  lines << "" << "## Calls internally"
771
- internal_calls.first(15).each { |c| lines << "- `#{c}`" }
776
+ internal_calls.first(INTERNAL_CALLS_SHOWN).each { |c| lines << "- `#{c}`" }
777
+ lines << "- _...and #{internal_calls.size - INTERNAL_CALLS_SHOWN} more_" if internal_calls.size > INTERNAL_CALLS_SHOWN
772
778
  end
773
779
  end
774
780
 
@@ -776,7 +782,8 @@ module RailsAiContext
776
782
  siblings = extract_sibling_methods(File.join(root, r[:file]), cleaned)
777
783
  if siblings.any?
778
784
  lines << "" << "## Sibling methods (same file)"
779
- siblings.first(10).each { |s| lines << "- `#{s}`" }
785
+ siblings.first(SIBLINGS_SHOWN).each { |s| lines << "- `#{s}`" }
786
+ lines << "- _...and #{siblings.size - SIBLINGS_SHOWN} more_" if siblings.size > SIBLINGS_SHOWN
780
787
  end
781
788
 
782
789
  lines << ""
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.36.2"
4
+ VERSION = "5.36.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.36.2
4
+ version: 5.36.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine