rails-ai-context 5.21.2 → 5.21.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/CONTEXT.md +8 -0
- data/docs/INTROSPECTORS.md +1 -1
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -5
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +1 -19
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +6 -4
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +11 -5
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +15 -8
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -1
- data/lib/rails_ai_context/portable_path.rb +55 -0
- data/lib/rails_ai_context/tools/get_service_pattern.rb +38 -26
- data/lib/rails_ai_context/tools/validate_semantics.rb +29 -9
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8afb263c0004a822a803eee097300e87dcb5a3a2de27d996a3cc5f11ea801051
|
|
4
|
+
data.tar.gz: 462a8ca8d7f4a66cd617af71322356908f513aa88ac7aec2046f32cf4279805f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f48e61abb15f942c9a3b41eced790ce41618f091e337d84c1d598486e74e3a58e3331c9acf971ad2fea0d0a0bb8a909caef4c6b9fd3196fba51dbe318532e97
|
|
7
|
+
data.tar.gz: 07e4b2759758da12d51805c0861e8a101fcdefdff55cba0db7714c6464473e486842fbd93c55b6480c8ce7b87325138fa18eb0920c38e219f0df3556a1ec0b19
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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.21.3] - 2026-08-12
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Foreign keys name the column that was declared, not one invented from the
|
|
13
|
+
target table.** `add_foreign_key "accounts", "accounts", column:
|
|
14
|
+
"moved_to_account_id"` was reported as `account_id`, a column the table does
|
|
15
|
+
not have, and two such keys on one table collided into the same invented
|
|
16
|
+
name. The schema listener dropped `column:`/`primary_key:` entirely, so the
|
|
17
|
+
convention was the only answer; it is now the fallback for the case Rails
|
|
18
|
+
omits the option in. The migration-replay path invented `statuse_id` from
|
|
19
|
+
`statuses` for the same reason. (#140)
|
|
20
|
+
- **A service's constructor is read from the service, not from the first
|
|
21
|
+
nested class in the file.** `rails_get_service_pattern` matched the first
|
|
22
|
+
`def initialize(` anywhere in the source, so a nested error class or query
|
|
23
|
+
builder supplied the signature for a service that defines no constructor,
|
|
24
|
+
and an agent reading it wrote `PostStatusService.new(message, accounts)` for
|
|
25
|
+
a class whose real interface is `.new.call(...)`. The constructor now comes
|
|
26
|
+
from the AST walk that already resolves the interface owner, and a
|
|
27
|
+
parenthesis-less `def initialize` is no longer missed. (#141)
|
|
28
|
+
- **Autoloader root_dirs are gem-relative, like initializer sources.** #139
|
|
29
|
+
fixed the sources and left `autoload.autoloaders[].root_dirs`, where every
|
|
30
|
+
engine's paths land, still absolute in `.ai-context.json`. Both introspectors
|
|
31
|
+
now share one `PortablePath`, which also collapses a gem the Gemfile takes
|
|
32
|
+
from `path:` or `git:`. A guard spec walks the whole generated context for
|
|
33
|
+
paths from the generating machine. (#142)
|
|
34
|
+
- **A model's `*_url` and `*_path` columns are not reported as missing route
|
|
35
|
+
helpers.** `rails_validate` read every receiverless call ending in `_url` as
|
|
36
|
+
a route helper, and an attribute reader has no `def` for the local-method
|
|
37
|
+
escape to find, so `shared_inbox_url` on Mastodon's `accounts` warned as a
|
|
38
|
+
broken route. The check now consults the model's own column list. (#143)
|
|
39
|
+
|
|
8
40
|
## [5.21.2] - 2026-08-12
|
|
9
41
|
|
|
10
42
|
### Fixed
|
data/CONTEXT.md
CHANGED
|
@@ -20,6 +20,14 @@ Avoid "format" for either sense - claude is not a file format, and the word coll
|
|
|
20
20
|
|
|
21
21
|
`Install::AiTool` is the one table of what each means: name, context files, MCP config shape, legacy leftovers. `Install::SelectionRecord` owns which ones the user picked - written to YAML always and to the initializer line inside a Rails app, read initializer-first because that is the file a user hand-edits.
|
|
22
22
|
|
|
23
|
+
## Path
|
|
24
|
+
|
|
25
|
+
Two senses, one per module, and neither is bare "path" in a name.
|
|
26
|
+
|
|
27
|
+
**Where code lives** - `PathResolver` answers which directories a kind of app code can occupy for a given root: conventional layout, packwerk packs, in-repo engines, configured extras.
|
|
28
|
+
|
|
29
|
+
**How a path is written down** - `PortablePath` rewrites one so it means the same thing on another machine, because what it touches ends up in `.ai-context.json` and the app commits that file. App paths go app-relative, gem paths keep the gem and version and drop the install prefix. "Relativize" always means this.
|
|
30
|
+
|
|
23
31
|
## Static tier
|
|
24
32
|
|
|
25
33
|
The mode where the app did not boot, or `--no-boot` was passed. What an introspector answers here is what it declared, never what a runtime check happened to detect. Every introspector in `INTROSPECTOR_MAP` extends `StaticTier` and names one of three kinds:
|
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -184,7 +184,7 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
|
|
|
184
184
|
| EnumsListener | Rails 7+ and legacy enum syntax, prefix/suffix options |
|
|
185
185
|
| CallbacksListener | All AR callback types, `after_commit` with `on:` resolution |
|
|
186
186
|
| MacrosListener | `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` |
|
|
187
|
-
| MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self` |
|
|
187
|
+
| MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self`; `include_initialize: true` adds the constructor a caller reports on its own |
|
|
188
188
|
| MixinsListener | `include`, `prepend`, `extend`, flagging the ones that reach the ancestor chain |
|
|
189
189
|
|
|
190
190
|
### The targeted-walk listeners
|
|
@@ -136,12 +136,10 @@ module RailsAiContext
|
|
|
136
136
|
"#{hit[:method]}: #{values.first}"
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
-
# Rails lists a path once per railtie that contributed it
|
|
139
|
+
# Rails lists a path once per railtie that contributed it, and every
|
|
140
|
+
# engine's paths sit under the machine's gem prefix rather than the app.
|
|
140
141
|
def relativize(paths)
|
|
141
|
-
|
|
142
|
-
s = p.to_s
|
|
143
|
-
s.start_with?(root) ? s.sub("#{root}/", "") : s
|
|
144
|
-
}.uniq
|
|
142
|
+
PortablePath.relativize_all(paths, root)
|
|
145
143
|
end
|
|
146
144
|
end
|
|
147
145
|
end
|
|
@@ -110,29 +110,11 @@ module RailsAiContext
|
|
|
110
110
|
return nil unless loc
|
|
111
111
|
|
|
112
112
|
path, line = loc
|
|
113
|
-
"#{
|
|
113
|
+
"#{PortablePath.relativize(path, root)}:#{line}"
|
|
114
114
|
rescue => e
|
|
115
115
|
$stderr.puts "[rails-ai-context] block_source_location failed: #{e.message}" if ENV["DEBUG"]
|
|
116
116
|
nil
|
|
117
117
|
end
|
|
118
|
-
|
|
119
|
-
# These sources are written into .ai-context.json, which the app commits.
|
|
120
|
-
# An absolute path is wrong on every other machine and carries the
|
|
121
|
-
# generating developer's home directory, so app paths go app-relative and
|
|
122
|
-
# gem paths keep the gem and version instead of the install prefix.
|
|
123
|
-
def relativize_source(path)
|
|
124
|
-
return path.sub("#{root}/", "") if path.start_with?(root)
|
|
125
|
-
|
|
126
|
-
gem_roots.each do |gem_root|
|
|
127
|
-
return path.delete_prefix(gem_root) if path.start_with?(gem_root)
|
|
128
|
-
end
|
|
129
|
-
path
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def gem_roots
|
|
133
|
-
@gem_roots ||= (Gem.path + [ Gem.default_dir ]).compact.uniq
|
|
134
|
-
.map { |dir| File.join(dir, "gems") + File::SEPARATOR }
|
|
135
|
-
end
|
|
136
118
|
end
|
|
137
119
|
end
|
|
138
120
|
end
|
|
@@ -8,8 +8,11 @@ module RailsAiContext
|
|
|
8
8
|
# visibility modifier calls, including inline forms like
|
|
9
9
|
# `private :method_name`.
|
|
10
10
|
class MethodsListener < BaseListener
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# A constructor is not part of the callable interface every other
|
|
12
|
+
# caller asks for, so it stays out unless one asks for it by name.
|
|
13
|
+
def initialize(include_initialize: false)
|
|
14
|
+
super()
|
|
15
|
+
@include_initialize = include_initialize
|
|
13
16
|
@visibility_stack = [ :public ]
|
|
14
17
|
@in_singleton_class = false
|
|
15
18
|
@singleton_depth = 0
|
|
@@ -84,8 +87,7 @@ module RailsAiContext
|
|
|
84
87
|
is_class_method = @in_singleton_class || node.receiver&.is_a?(Prism::SelfNode)
|
|
85
88
|
method_name = node.name.to_s
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
return if method_name == "initialize" && !is_class_method
|
|
90
|
+
return if method_name == "initialize" && !is_class_method && !@include_initialize
|
|
89
91
|
|
|
90
92
|
# Inline visibility (`private :foo`) takes precedence over positional
|
|
91
93
|
visibility = @inline_visibility_stack.last[method_name] || @visibility_stack.last
|
|
@@ -64,12 +64,18 @@ module RailsAiContext
|
|
|
64
64
|
to_arg = args[1]
|
|
65
65
|
return unless from_arg.is_a?(Prism::StringNode) && to_arg.is_a?(Prism::StringNode)
|
|
66
66
|
|
|
67
|
+
options = extract_keyword_options(node)
|
|
68
|
+
|
|
67
69
|
@results << {
|
|
68
|
-
type:
|
|
69
|
-
from:
|
|
70
|
-
to:
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
type: :foreign_key,
|
|
71
|
+
from: from_arg.unescaped,
|
|
72
|
+
to: to_arg.unescaped,
|
|
73
|
+
# Absent means the Rails convention holds; naming it here would
|
|
74
|
+
# make a declared column indistinguishable from a guessed one.
|
|
75
|
+
column: options[:column]&.to_s,
|
|
76
|
+
primary_key: options[:primary_key]&.to_s,
|
|
77
|
+
location: node.location.start_line
|
|
78
|
+
}.compact
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
def extract_enum(node)
|
|
@@ -298,6 +298,17 @@ module RailsAiContext
|
|
|
298
298
|
entry
|
|
299
299
|
end
|
|
300
300
|
|
|
301
|
+
# Rails omits column:/primary_key: only where the convention holds, so
|
|
302
|
+
# the fallback is what was declared rather than a guess. Both dump
|
|
303
|
+
# readers land here so the convention lives in one place.
|
|
304
|
+
def foreign_key_entry(from, to, column, primary_key)
|
|
305
|
+
{
|
|
306
|
+
from_table: from, to_table: to,
|
|
307
|
+
column: column&.to_s || "#{to.to_s.singularize}_id",
|
|
308
|
+
primary_key: primary_key&.to_s || "id"
|
|
309
|
+
}
|
|
310
|
+
end
|
|
311
|
+
|
|
301
312
|
def static_index(index)
|
|
302
313
|
columns = index[:columns]
|
|
303
314
|
return nil if columns.empty?
|
|
@@ -330,12 +341,9 @@ module RailsAiContext
|
|
|
330
341
|
end
|
|
331
342
|
|
|
332
343
|
schema.foreign_keys.each do |fk|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
from_table: fk[:from], to_table: fk[:to],
|
|
337
|
-
column: "#{fk[:to].singularize}_id", primary_key: "id"
|
|
338
|
-
})
|
|
344
|
+
tables[fk[:from]]&.dig(:foreign_keys)&.push(
|
|
345
|
+
foreign_key_entry(fk[:from], fk[:to], fk[:column], fk[:primary_key])
|
|
346
|
+
)
|
|
339
347
|
end
|
|
340
348
|
|
|
341
349
|
check_constraints = schema.check_constraints
|
|
@@ -815,8 +823,7 @@ module RailsAiContext
|
|
|
815
823
|
to = entry[:to_table]
|
|
816
824
|
return unless tables[from]
|
|
817
825
|
opts = entry[:options] || {}
|
|
818
|
-
|
|
819
|
-
tables[from][:foreign_keys] << { from_table: from, to_table: to, column: column, primary_key: "id" }
|
|
826
|
+
tables[from][:foreign_keys] << foreign_key_entry(from, to, opts[:column], opts[:primary_key])
|
|
820
827
|
end
|
|
821
828
|
end
|
|
822
829
|
|
|
@@ -117,7 +117,10 @@ module RailsAiContext
|
|
|
117
117
|
when :add_index
|
|
118
118
|
schema[:tables][event[:table]]&.dig(:indexes)&.push(index_entry(event))
|
|
119
119
|
when :foreign_key
|
|
120
|
-
schema[:foreign_keys] << {
|
|
120
|
+
schema[:foreign_keys] << {
|
|
121
|
+
from: event[:from], to: event[:to],
|
|
122
|
+
column: event[:column], primary_key: event[:primary_key]
|
|
123
|
+
}.compact
|
|
121
124
|
when :enum
|
|
122
125
|
schema[:enums] << { name: event[:name], values: event[:values] }
|
|
123
126
|
when :check_constraint
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# Rewrites a path so it means the same thing on another machine. What is
|
|
5
|
+
# generated here ends up in .ai-context.json, which the app commits: an
|
|
6
|
+
# absolute path is wrong on every other checkout, wrong again after a Ruby
|
|
7
|
+
# or gem upgrade, and it carries the generating developer's home directory
|
|
8
|
+
# into the repository. App paths go app-relative; gem paths keep the gem
|
|
9
|
+
# and version and drop the install prefix.
|
|
10
|
+
#
|
|
11
|
+
# Distinct from PathResolver, which answers where a kind of app code lives.
|
|
12
|
+
module PortablePath
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def relativize(path, root)
|
|
16
|
+
path = path.to_s
|
|
17
|
+
root = root.to_s
|
|
18
|
+
return path.delete_prefix("#{root}/") if !root.empty? && path.start_with?("#{root}/")
|
|
19
|
+
|
|
20
|
+
gem_roots.each do |gem_root|
|
|
21
|
+
return path.delete_prefix(gem_root) if path.start_with?(gem_root)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
gem_checkouts.each do |dir, name|
|
|
25
|
+
return File.join(name, path.delete_prefix(dir)) if path.start_with?(dir)
|
|
26
|
+
end
|
|
27
|
+
path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def relativize_all(paths, root)
|
|
31
|
+
Array(paths).map { |path| relativize(path, root) }.uniq
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Every prefix a gem can be unpacked under, trailing separator included so
|
|
35
|
+
# a prefix cannot match a sibling directory that merely starts the same.
|
|
36
|
+
def gem_roots
|
|
37
|
+
@gem_roots ||= (Gem.path + [ Gem.default_dir ]).compact.uniq
|
|
38
|
+
.map { |dir| File.join(dir, "gems") + File::SEPARATOR }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A gem the Gemfile takes from path: or git: is never unpacked under a
|
|
42
|
+
# gem root, so its own checkout is the only prefix that names it. Longest
|
|
43
|
+
# first, because one checkout can sit inside another.
|
|
44
|
+
def gem_checkouts
|
|
45
|
+
@gem_checkouts ||= Gem.loaded_specs.each_value.filter_map { |spec|
|
|
46
|
+
dir = spec.full_gem_path.to_s
|
|
47
|
+
next if dir.empty? || gem_roots.any? { |gem_root| dir.start_with?(gem_root) }
|
|
48
|
+
[ dir + File::SEPARATOR, spec.full_name ]
|
|
49
|
+
}.sort_by { |dir, _name| -dir.length }
|
|
50
|
+
rescue => e
|
|
51
|
+
$stderr.puts "[rails-ai-context] PortablePath.gem_checkouts failed: #{e.message}" if ENV["DEBUG"]
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -81,12 +81,14 @@ module RailsAiContext
|
|
|
81
81
|
lines = [ "# #{class_name}", "" ]
|
|
82
82
|
lines << "**File:** `#{relative}` (#{count_phrase(line_count, "line")})"
|
|
83
83
|
|
|
84
|
+
owned = owned_methods(source, constant_for(file, services_dir))
|
|
85
|
+
|
|
84
86
|
# Initialize params
|
|
85
|
-
init_params = extract_initialize_params(
|
|
87
|
+
init_params = extract_initialize_params(owned)
|
|
86
88
|
lines << "**Initialize:** `#{init_params}`" if init_params
|
|
87
89
|
|
|
88
90
|
# Public methods
|
|
89
|
-
public_methods = extract_public_methods(
|
|
91
|
+
public_methods = extract_public_methods(owned)
|
|
90
92
|
if public_methods.any?
|
|
91
93
|
lines << "" << "## Public Methods"
|
|
92
94
|
public_methods.each { |m| lines << "- `#{m}`" }
|
|
@@ -139,20 +141,23 @@ module RailsAiContext
|
|
|
139
141
|
relative = file.sub("#{root}/", "")
|
|
140
142
|
class_name = extract_class_name(source) || File.basename(file, ".rb").camelize
|
|
141
143
|
line_count = source.lines.size
|
|
142
|
-
|
|
144
|
+
owned = owned_methods(source, constant_for(file, services_dir))
|
|
145
|
+
public_methods = extract_public_methods(owned)
|
|
146
|
+
init_params = extract_initialize_params(owned)
|
|
143
147
|
|
|
144
148
|
pattern_stats[:total] += 1
|
|
145
|
-
has_initialize =
|
|
149
|
+
has_initialize = !init_params.nil?
|
|
146
150
|
pattern_stats[:initialize_call] += 1 if has_initialize && public_methods.any? { |m| m.start_with?("call") }
|
|
147
151
|
pattern_stats[:initialize_single_method] += 1 if has_initialize && public_methods.size == 1
|
|
148
|
-
pattern_stats[:class_method_call] += 1 if
|
|
152
|
+
pattern_stats[:class_method_call] += 1 if owned.any? { |m| m[:scope] == :class && m[:name] == "call" }
|
|
149
153
|
pattern_stats[:result_object] += 1 if source.match?(/Result\.new|OpenStruct\.new|Struct\.new|\.success|\.failure/)
|
|
150
154
|
|
|
151
155
|
service_data << {
|
|
152
156
|
file: relative,
|
|
153
157
|
class_name: class_name,
|
|
154
158
|
line_count: line_count,
|
|
155
|
-
public_methods: public_methods
|
|
159
|
+
public_methods: public_methods,
|
|
160
|
+
init_params: init_params
|
|
156
161
|
}
|
|
157
162
|
end
|
|
158
163
|
|
|
@@ -183,13 +188,12 @@ module RailsAiContext
|
|
|
183
188
|
methods_str = s[:public_methods].any? ? s[:public_methods].join(", ") : "none"
|
|
184
189
|
lines << "- **Methods:** #{methods_str}"
|
|
185
190
|
|
|
191
|
+
lines << "- **Initialize:** `#{s[:init_params]}`" if s[:init_params]
|
|
192
|
+
|
|
186
193
|
# Read source for additional detail
|
|
187
194
|
full_path = File.join(root, s[:file])
|
|
188
195
|
source = safe_read(full_path)
|
|
189
196
|
if source
|
|
190
|
-
init_params = extract_initialize_params(source)
|
|
191
|
-
lines << "- **Initialize:** `#{init_params}`" if init_params
|
|
192
|
-
|
|
193
197
|
side_effects = extract_side_effects(source)
|
|
194
198
|
lines << "- **Side effects:** #{side_effects.join(', ')}" if side_effects.any?
|
|
195
199
|
|
|
@@ -216,17 +220,11 @@ module RailsAiContext
|
|
|
216
220
|
match[1] if match
|
|
217
221
|
end
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
# A service's interface is the public methods of the service class
|
|
226
|
-
# itself. Nesting a query builder or a set of condition objects inside
|
|
227
|
-
# the service that uses them is a normal way to organise a large one, and
|
|
228
|
-
# a line scan cannot see that: it reported the nested class's first `def`
|
|
229
|
-
# as the entry point, and a `private` inside a nested class hid the real
|
|
223
|
+
# The methods the service class defines itself. Nesting a query builder
|
|
224
|
+
# or a set of condition objects inside the service that uses them is a
|
|
225
|
+
# normal way to organise a large one, and a line scan cannot see that:
|
|
226
|
+
# it reported the nested class's first `def` as the entry point, its
|
|
227
|
+
# constructor as the service's, and a `private` inside it hid the real
|
|
230
228
|
# `call` that followed the nested class's `end`.
|
|
231
229
|
#
|
|
232
230
|
# `expected_constant` comes from the path rather than the source, the way
|
|
@@ -234,22 +232,36 @@ module RailsAiContext
|
|
|
234
232
|
# and only the path carries the namespace. A file that does not follow
|
|
235
233
|
# the convention falls back to the shallowest nesting, which is the
|
|
236
234
|
# outermost definition in the file.
|
|
237
|
-
|
|
235
|
+
#
|
|
236
|
+
# One walk per file: the interface and the constructor have to resolve
|
|
237
|
+
# the same owner, and a second walk could pick a different one.
|
|
238
|
+
private_class_method def self.owned_methods(source, expected_constant = nil)
|
|
238
239
|
ast = Introspectors::SourceIntrospector.walk_source(
|
|
239
|
-
source, { methods: Introspectors::Listeners::MethodsListener }
|
|
240
|
+
source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) } }
|
|
240
241
|
)
|
|
241
242
|
methods = ast[:methods] || []
|
|
242
243
|
return [] if methods.empty?
|
|
243
244
|
|
|
244
245
|
owner = primary_owner(methods, expected_constant)
|
|
245
|
-
|
|
246
|
-
methods.select { |m| m[:visibility] == :public && m[:owner].join("::") == owner }
|
|
247
|
-
.map { |m| m[:signature] }
|
|
246
|
+
methods.select { |m| m[:owner].join("::") == owner }
|
|
248
247
|
rescue => e
|
|
249
|
-
$stderr.puts "[rails-ai-context]
|
|
248
|
+
$stderr.puts "[rails-ai-context] owned_methods AST failed: #{e.message}" if ENV["DEBUG"]
|
|
250
249
|
[]
|
|
251
250
|
end
|
|
252
251
|
|
|
252
|
+
# A service that defines no constructor answers `.new` with no arguments.
|
|
253
|
+
private_class_method def self.extract_initialize_params(owned)
|
|
254
|
+
ctor = owned.find { |m| m[:name] == "initialize" && m[:scope] == :instance }
|
|
255
|
+
ctor && ctor[:signature]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The constructor is reported on its own line, not as part of the
|
|
259
|
+
# interface, which is why it is dropped here rather than at the walk.
|
|
260
|
+
private_class_method def self.extract_public_methods(owned)
|
|
261
|
+
owned.select { |m| m[:visibility] == :public && m[:name] != "initialize" }
|
|
262
|
+
.map { |m| m[:signature] }
|
|
263
|
+
end
|
|
264
|
+
|
|
253
265
|
private_class_method def self.primary_owner(methods, expected_constant)
|
|
254
266
|
owners = methods.map { |m| m[:owner].join("::") }
|
|
255
267
|
return expected_constant if expected_constant && owners.include?(expected_constant)
|
|
@@ -203,23 +203,23 @@ module RailsAiContext
|
|
|
203
203
|
if file.end_with?(".html.erb", ".erb")
|
|
204
204
|
if visitor
|
|
205
205
|
warnings.concat(check_partial_existence_ast(file, visitor))
|
|
206
|
-
warnings.concat(check_route_helpers_ast(visitor, context))
|
|
206
|
+
warnings.concat(check_route_helpers_ast(file, visitor, context))
|
|
207
207
|
else
|
|
208
208
|
warnings.concat(check_partial_existence_regex(file, content))
|
|
209
|
-
warnings.concat(check_route_helpers_regex(content, context))
|
|
209
|
+
warnings.concat(check_route_helpers_regex(file, content, context))
|
|
210
210
|
end
|
|
211
211
|
warnings.concat(check_stimulus_controllers(content, context))
|
|
212
212
|
warnings.concat(check_instance_variable_usage(file, content, context))
|
|
213
213
|
warnings.concat(check_respond_to_template_existence(file, content))
|
|
214
214
|
elsif file.end_with?(".rb")
|
|
215
215
|
if visitor
|
|
216
|
-
warnings.concat(check_route_helpers_ast(visitor, context))
|
|
216
|
+
warnings.concat(check_route_helpers_ast(file, visitor, context))
|
|
217
217
|
warnings.concat(check_partial_existence_ast(file, visitor, qualified_only: true))
|
|
218
218
|
warnings.concat(check_column_references_ast(file, visitor, context))
|
|
219
219
|
warnings.concat(check_strong_params_ast(file, visitor, context))
|
|
220
220
|
warnings.concat(check_callback_existence_ast(file, visitor, context))
|
|
221
221
|
else
|
|
222
|
-
warnings.concat(check_route_helpers_regex(content, context))
|
|
222
|
+
warnings.concat(check_route_helpers_regex(file, content, context))
|
|
223
223
|
warnings.concat(check_column_references_regex(file, content, context))
|
|
224
224
|
end
|
|
225
225
|
# Cache-only checks (no AST needed)
|
|
@@ -313,19 +313,22 @@ module RailsAiContext
|
|
|
313
313
|
ASSET_HELPER_PREFIXES = %w[image asset font stylesheet javascript audio video file compute_asset auto_discovery_link favicon].freeze
|
|
314
314
|
DEVISE_HELPER_NAMES = %w[session registration password confirmation unlock omniauth_callback user_session user_registration user_password user_confirmation user_unlock].freeze
|
|
315
315
|
|
|
316
|
-
private_class_method def self.check_route_helpers_ast(visitor, context)
|
|
316
|
+
private_class_method def self.check_route_helpers_ast(file, visitor, context)
|
|
317
317
|
warnings = []
|
|
318
318
|
routes = context[:routes]
|
|
319
319
|
return warnings unless routes && routes[:by_controller]
|
|
320
320
|
valid_names = build_route_name_set(routes)
|
|
321
321
|
return warnings if valid_names.empty?
|
|
322
322
|
|
|
323
|
+
columns = helper_shaped_columns(file, context)
|
|
324
|
+
|
|
323
325
|
seen = Set.new
|
|
324
326
|
visitor.route_helper_calls.each do |call|
|
|
325
327
|
helper = call[:name]
|
|
326
328
|
next if seen.include?(helper)
|
|
327
329
|
seen << helper
|
|
328
330
|
next if visitor.local_route_method_defined?(helper, call[:scope], call[:method_kind])
|
|
331
|
+
next if columns.include?(helper)
|
|
329
332
|
|
|
330
333
|
name = helper.sub(/_(path|url)\z/, "")
|
|
331
334
|
next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
|
|
@@ -338,13 +341,15 @@ module RailsAiContext
|
|
|
338
341
|
end
|
|
339
342
|
|
|
340
343
|
# Regex fallback
|
|
341
|
-
private_class_method def self.check_route_helpers_regex(content, context)
|
|
344
|
+
private_class_method def self.check_route_helpers_regex(file, content, context)
|
|
342
345
|
warnings = []
|
|
343
346
|
routes = context[:routes]
|
|
344
347
|
return warnings unless routes && routes[:by_controller]
|
|
345
348
|
valid_names = build_route_name_set(routes)
|
|
346
349
|
return warnings if valid_names.empty?
|
|
347
350
|
|
|
351
|
+
columns = helper_shaped_columns(file, context)
|
|
352
|
+
|
|
348
353
|
seen = Set.new
|
|
349
354
|
local_method_names = local_route_like_method_names(content)
|
|
350
355
|
content.scan(/\b(\w+)_(path|url)\b/).each do |match|
|
|
@@ -353,6 +358,7 @@ module RailsAiContext
|
|
|
353
358
|
next if seen.include?(helper)
|
|
354
359
|
seen << helper
|
|
355
360
|
next if local_method_names.include?(helper)
|
|
361
|
+
next if columns.include?(helper)
|
|
356
362
|
next if ASSET_HELPER_PREFIXES.any? { |p| name.start_with?(p) }
|
|
357
363
|
next if DEVISE_HELPER_NAMES.include?(name)
|
|
358
364
|
next if %w[edit new polymorphic].include?(name)
|
|
@@ -361,6 +367,18 @@ module RailsAiContext
|
|
|
361
367
|
warnings
|
|
362
368
|
end
|
|
363
369
|
|
|
370
|
+
# A column named `shared_inbox_url` reads as a receiverless call to a
|
|
371
|
+
# route helper, and the attribute reader it resolves to has no `def`
|
|
372
|
+
# anywhere to find it by. Columns only: an association named like a
|
|
373
|
+
# helper is not a reader, and suppressing on it would hide a real
|
|
374
|
+
# missing route.
|
|
375
|
+
private_class_method def self.helper_shaped_columns(file, context)
|
|
376
|
+
valid = model_valid_columns(file, context)
|
|
377
|
+
return Set.new unless valid
|
|
378
|
+
|
|
379
|
+
valid[:table_columns].select { |c| c.to_s.end_with?("_path", "_url") }.to_set
|
|
380
|
+
end
|
|
381
|
+
|
|
364
382
|
private_class_method def self.local_route_like_method_names(content)
|
|
365
383
|
content.scan(/^\s*(?:(?:private|protected|public|private_class_method)\s+)*def\s+(?:self\.)?(\w+_(?:path|url))\b/).flatten.to_set
|
|
366
384
|
end
|
|
@@ -432,14 +450,16 @@ module RailsAiContext
|
|
|
432
450
|
table_data = schema[:tables] && schema[:tables][table_name]
|
|
433
451
|
return nil unless table_data
|
|
434
452
|
|
|
435
|
-
|
|
436
|
-
table_data[:columns]&.each { |c|
|
|
453
|
+
table_columns = Set.new
|
|
454
|
+
table_data[:columns]&.each { |c| table_columns << c[:name] }
|
|
455
|
+
|
|
456
|
+
columns = table_columns.dup
|
|
437
457
|
model_data[:associations]&.each do |a|
|
|
438
458
|
columns << a[:name] if a[:name]
|
|
439
459
|
columns << a[:foreign_key] if a[:foreign_key]
|
|
440
460
|
end
|
|
441
461
|
|
|
442
|
-
{ columns: columns, table: table_name, model: model_name, model_data: model_data }
|
|
462
|
+
{ columns: columns, table_columns: table_columns, table: table_name, model: model_name, model_data: model_data }
|
|
443
463
|
end
|
|
444
464
|
|
|
445
465
|
# ── CHECK 4: Strong params vs schema (AST) ───────────────────────
|
data/server.json
CHANGED
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.21.
|
|
4
|
+
version: 5.21.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- crisnahine
|
|
@@ -384,6 +384,7 @@ files:
|
|
|
384
384
|
- lib/rails_ai_context/output_guard.rb
|
|
385
385
|
- lib/rails_ai_context/path_resolver.rb
|
|
386
386
|
- lib/rails_ai_context/polyfill/data.rb
|
|
387
|
+
- lib/rails_ai_context/portable_path.rb
|
|
387
388
|
- lib/rails_ai_context/presets.rb
|
|
388
389
|
- lib/rails_ai_context/redaction.rb
|
|
389
390
|
- lib/rails_ai_context/resources.rb
|