rails-ai-context 5.21.1 → 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 +54 -0
- data/CONTEXT.md +8 -0
- data/docs/INTROSPECTORS.md +2 -1
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +3 -5
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +6 -4
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +60 -0
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +11 -5
- data/lib/rails_ai_context/introspectors/model_introspector.rb +12 -0
- 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/introspectors/security_introspector.rb +8 -2
- data/lib/rails_ai_context/introspectors/source_introspector.rb +2 -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 +3 -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,60 @@ 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
|
+
|
|
40
|
+
## [5.21.2] - 2026-08-12
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **The static tier no longer drops a model's concerns.** Nothing in the
|
|
45
|
+
listener stack collected `include`/`prepend`, so `rails_get_model_details`
|
|
46
|
+
answered `--no-boot` with no Concerns section at all - not even an
|
|
47
|
+
`[UNAVAILABLE]` marker - for a model that includes one. A new
|
|
48
|
+
`MixinsListener` reads them from the source, filtered to the mixins
|
|
49
|
+
reflection would report so both tiers answer the same question. (#137)
|
|
50
|
+
- **Cookie settings that Rails computes per request are named, not
|
|
51
|
+
inspected.** Rails 8 defaults `same_site` to a lambda, which reached
|
|
52
|
+
`.ai-context.json` as `#<Proc:0x...>` - an address that answers nothing and
|
|
53
|
+
moves on every boot, so the file was rewritten on every run even when the app
|
|
54
|
+
had not changed. Callables now render `[UNAVAILABLE: computed at request
|
|
55
|
+
time]`, nested ones included. (#138)
|
|
56
|
+
- **Initializer sources are gem-relative instead of absolute machine paths.**
|
|
57
|
+
Every one of them was written into `.ai-context.json` as
|
|
58
|
+
`/Users/<name>/.rvm/gems/ruby-3.4.9/gems/railties-8.0.5.1/...`, which is
|
|
59
|
+
wrong on every other machine, wrong again after a Ruby upgrade, and carries
|
|
60
|
+
the generating developer's home directory into the app's repository. (#139)
|
|
61
|
+
|
|
8
62
|
## [5.21.1] - 2026-08-12
|
|
9
63
|
|
|
10
64
|
### 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,8 @@ 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
|
+
| MixinsListener | `include`, `prepend`, `extend`, flagging the ones that reach the ancestor chain |
|
|
188
189
|
|
|
189
190
|
### The targeted-walk listeners
|
|
190
191
|
|
|
@@ -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,8 +110,7 @@ module RailsAiContext
|
|
|
110
110
|
return nil unless loc
|
|
111
111
|
|
|
112
112
|
path, line = loc
|
|
113
|
-
|
|
114
|
-
"#{path}:#{line}"
|
|
113
|
+
"#{PortablePath.relativize(path, root)}:#{line}"
|
|
115
114
|
rescue => e
|
|
116
115
|
$stderr.puts "[rails-ai-context] block_source_location failed: #{e.message}" if ENV["DEBUG"]
|
|
117
116
|
nil
|
|
@@ -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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module Introspectors
|
|
7
|
+
module Listeners
|
|
8
|
+
# Detects `include`, `prepend` and `extend` with a constant argument.
|
|
9
|
+
#
|
|
10
|
+
# `ancestor` marks the ones that reach the ancestor chain, which is what
|
|
11
|
+
# reflection reports - so a caller can answer the same question the
|
|
12
|
+
# booted tier answers.
|
|
13
|
+
class MixinsListener < BaseListener
|
|
14
|
+
MIXIN_MACROS = %i[include prepend extend].to_set.freeze
|
|
15
|
+
ANCESTOR_MACROS = %i[include prepend].to_set.freeze
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
super
|
|
19
|
+
@singleton_depth = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `include` inside `class << self` lands on the singleton class, so it
|
|
23
|
+
# never reaches `ancestors` - extend semantics wearing an include's name.
|
|
24
|
+
def on_singleton_class_node_enter(_node)
|
|
25
|
+
@singleton_depth += 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_singleton_class_node_leave(_node)
|
|
29
|
+
@singleton_depth -= 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def on_call_node_enter(node)
|
|
33
|
+
return unless node.receiver.nil?
|
|
34
|
+
return unless MIXIN_MACROS.include?(node.name)
|
|
35
|
+
|
|
36
|
+
(node.arguments&.arguments || []).each do |arg|
|
|
37
|
+
name = constant_name(arg)
|
|
38
|
+
next unless name
|
|
39
|
+
|
|
40
|
+
@results << {
|
|
41
|
+
macro: node.name,
|
|
42
|
+
name: name,
|
|
43
|
+
ancestor: @singleton_depth.zero? && ANCESTOR_MACROS.include?(node.name),
|
|
44
|
+
location: node.location.start_line,
|
|
45
|
+
confidence: confidence_for(node)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def constant_name(node)
|
|
53
|
+
return unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
54
|
+
|
|
55
|
+
extract_value(node)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -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)
|
|
@@ -591,11 +591,23 @@ module RailsAiContext
|
|
|
591
591
|
# with callbacks found" and then raised a TypeError on a Hash lookup
|
|
592
592
|
# against an Array.
|
|
593
593
|
callbacks: group_callbacks_by_type(data[:callbacks]),
|
|
594
|
+
concerns: static_concerns(data[:mixins]),
|
|
594
595
|
macros: data[:macros],
|
|
595
596
|
methods: data[:methods]
|
|
596
597
|
}
|
|
597
598
|
end
|
|
598
599
|
|
|
600
|
+
# Only the mixins reflection would report, so both tiers answer the same
|
|
601
|
+
# question. This sees the model file alone, where the booted tier also
|
|
602
|
+
# walks what its superclass and its concerns pulled in.
|
|
603
|
+
def static_concerns(mixins)
|
|
604
|
+
(mixins || [])
|
|
605
|
+
.select { |mixin| mixin[:ancestor] }
|
|
606
|
+
.map { |mixin| mixin[:name] }
|
|
607
|
+
.reject { |name| framework_concern?(name) }
|
|
608
|
+
.uniq
|
|
609
|
+
end
|
|
610
|
+
|
|
599
611
|
# Mongoid documents are invisible to ActiveRecord reflection, so both
|
|
600
612
|
# tiers parse them from source. Fields and embedded relations come from
|
|
601
613
|
# the Mongoid listener; shared-name macros (belongs_to, has_many,
|
|
@@ -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
|
|
@@ -246,11 +246,17 @@ module RailsAiContext
|
|
|
246
246
|
{}
|
|
247
247
|
end
|
|
248
248
|
|
|
249
|
+
# Rails 8 defaults same_site to a lambda. Left alone it reaches
|
|
250
|
+
# .ai-context.json as an address that answers nothing and moves every
|
|
251
|
+
# boot, so the file is rewritten on every run. Nested one level down it
|
|
252
|
+
# is worse: the encoder renders it `{}`, which reads as a setting the app
|
|
253
|
+
# left empty.
|
|
249
254
|
def serializable(value)
|
|
250
255
|
case value
|
|
251
256
|
when Symbol then value.to_s
|
|
252
|
-
when Hash then value.
|
|
253
|
-
when Array then value.map(
|
|
257
|
+
when Hash then value.each_with_object({}) { |(key, nested), result| result[key.to_s] = serializable(nested) }
|
|
258
|
+
when Array then value.map { |nested| serializable(nested) }
|
|
259
|
+
when Proc then Confidence.unavailable("computed at request time")
|
|
254
260
|
else value
|
|
255
261
|
end
|
|
256
262
|
end
|
|
@@ -20,7 +20,8 @@ module RailsAiContext
|
|
|
20
20
|
enums: Listeners::EnumsListener,
|
|
21
21
|
callbacks: Listeners::CallbacksListener,
|
|
22
22
|
macros: Listeners::MacrosListener,
|
|
23
|
-
methods: Listeners::MethodsListener
|
|
23
|
+
methods: Listeners::MethodsListener,
|
|
24
|
+
mixins: Listeners::MixinsListener
|
|
24
25
|
}.freeze
|
|
25
26
|
|
|
26
27
|
# Introspect a file on disk (cached parse) with default listeners.
|
|
@@ -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
|
|
@@ -345,6 +345,7 @@ files:
|
|
|
345
345
|
- lib/rails_ai_context/introspectors/listeners/methods_listener.rb
|
|
346
346
|
- lib/rails_ai_context/introspectors/listeners/middleware_config_listener.rb
|
|
347
347
|
- lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb
|
|
348
|
+
- lib/rails_ai_context/introspectors/listeners/mixins_listener.rb
|
|
348
349
|
- lib/rails_ai_context/introspectors/listeners/model_reference_listener.rb
|
|
349
350
|
- lib/rails_ai_context/introspectors/listeners/mongoid_fields_listener.rb
|
|
350
351
|
- lib/rails_ai_context/introspectors/listeners/mount_listener.rb
|
|
@@ -383,6 +384,7 @@ files:
|
|
|
383
384
|
- lib/rails_ai_context/output_guard.rb
|
|
384
385
|
- lib/rails_ai_context/path_resolver.rb
|
|
385
386
|
- lib/rails_ai_context/polyfill/data.rb
|
|
387
|
+
- lib/rails_ai_context/portable_path.rb
|
|
386
388
|
- lib/rails_ai_context/presets.rb
|
|
387
389
|
- lib/rails_ai_context/redaction.rb
|
|
388
390
|
- lib/rails_ai_context/resources.rb
|