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