rails-ai-context 5.36.4 → 5.36.5

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