rails-ai-context 5.26.0 → 5.27.0
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 +140 -0
- data/README.md +3 -1
- data/docs/COMPATIBILITY.md +4 -1
- data/docs/CONFIGURATION.md +27 -0
- data/docs/FAQ.md +13 -0
- data/docs/INTROSPECTORS.md +1 -1
- data/docs/QUICKSTART.md +5 -1
- data/docs/SETUP.md +4 -0
- data/docs/STANDALONE.md +3 -0
- data/docs/TOOLS.md +12 -6
- data/exe/rails-ai-context +46 -16
- data/lib/generators/rails_ai_context/install/install_generator.rb +54 -11
- data/lib/rails_ai_context/action_filters.rb +42 -5
- data/lib/rails_ai_context/configuration.rb +8 -1
- data/lib/rails_ai_context/doctor.rb +4 -0
- data/lib/rails_ai_context/erb_source.rb +45 -0
- data/lib/rails_ai_context/gem_lock.rb +18 -2
- data/lib/rails_ai_context/hydrators/controller_hydrator.rb +1 -2
- data/lib/rails_ai_context/hydrators/model_hints.rb +5 -2
- data/lib/rails_ai_context/install/program.rb +33 -9
- data/lib/rails_ai_context/install/selection_record.rb +72 -0
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +9 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +87 -8
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +17 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +47 -7
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +58 -4
- data/lib/rails_ai_context/introspectors/job_introspector.rb +36 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +36 -0
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +22 -4
- data/lib/rails_ai_context/introspectors/model_introspector.rb +56 -15
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +70 -2
- data/lib/rails_ai_context/introspectors/route_introspector.rb +30 -13
- data/lib/rails_ai_context/introspectors/view_introspector.rb +1 -0
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +16 -7
- data/lib/rails_ai_context/output_guard.rb +43 -3
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +34 -8
- data/lib/rails_ai_context/tools/analyze_feature.rb +7 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +96 -15
- data/lib/rails_ai_context/tools/generate_test.rb +110 -14
- data/lib/rails_ai_context/tools/get_active_support.rb +5 -1
- data/lib/rails_ai_context/tools/get_api.rb +24 -0
- data/lib/rails_ai_context/tools/get_callbacks.rb +6 -6
- data/lib/rails_ai_context/tools/get_controllers.rb +33 -3
- data/lib/rails_ai_context/tools/get_env.rb +68 -31
- data/lib/rails_ai_context/tools/get_gems.rb +9 -2
- data/lib/rails_ai_context/tools/get_job_pattern.rb +25 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -20
- data/lib/rails_ai_context/tools/get_routes.rb +6 -2
- data/lib/rails_ai_context/tools/get_service_pattern.rb +81 -20
- data/lib/rails_ai_context/tools/get_view.rb +9 -1
- data/lib/rails_ai_context/tools/onboard.rb +5 -0
- data/lib/rails_ai_context/tools/query.rb +6 -1
- data/lib/rails_ai_context/tools/search_code.rb +38 -7
- data/lib/rails_ai_context/tools/validate_semantics.rb +32 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +22 -2
- data/lib/rails_ai_context/view_file.rb +38 -0
- data/lib/rails_ai_context/watcher.rb +4 -1
- data/lib/rails_ai_context.rb +7 -1
- data/server.json +1 -1
- metadata +2 -1
|
@@ -55,7 +55,11 @@ module RailsAiContext
|
|
|
55
55
|
.reject { |f| inherited_skips.include?(f[:name].to_s) && !f[:declared] }
|
|
56
56
|
# Keyed by kind and name: `after_action :audit` and `before_action :audit`
|
|
57
57
|
# are two entries in the chain and Rails runs both.
|
|
58
|
-
|
|
58
|
+
# The whole ancestor entry, not only its `from:`: an entry the walk
|
|
59
|
+
# could not attribute carries `provenance` instead, and merging only
|
|
60
|
+
# `from` dropped that on the tier the label exists for.
|
|
61
|
+
attribution_of = parent.to_h { |f| [ entry_key(f), f.slice(:from, :provenance) ] }
|
|
62
|
+
declared_on = attribution_of
|
|
59
63
|
declared_names = declared.map { |f| entry_key(f) }.to_set
|
|
60
64
|
|
|
61
65
|
# A filter this body declares is its own, whatever an ancestor declares
|
|
@@ -66,7 +70,7 @@ module RailsAiContext
|
|
|
66
70
|
inherited = mark_conditional_skips(
|
|
67
71
|
parent.reject { |f| declared_names.include?(entry_key(f)) } +
|
|
68
72
|
applicable.select(&inherited_here)
|
|
69
|
-
.map { |f| f.
|
|
73
|
+
.map { |f| f.except(:from, :provenance).merge(attribution_of[entry_key(f)]) }, conditions, action
|
|
70
74
|
)
|
|
71
75
|
|
|
72
76
|
{ own: own,
|
|
@@ -178,11 +182,17 @@ module RailsAiContext
|
|
|
178
182
|
attributed = Set.new
|
|
179
183
|
conditions = {}
|
|
180
184
|
declares = Set.new
|
|
185
|
+
# Where in the walk each entry was first seen, so the list can be
|
|
186
|
+
# emitted root first while the order inside one class is kept.
|
|
187
|
+
positions = {}
|
|
188
|
+
evidence = {}
|
|
189
|
+
depth = 0
|
|
181
190
|
dropped = skipped.map(&:to_s).to_set
|
|
182
191
|
name = Introspectors::ActionResolver.resolve_entry_name(controllers, parent_class, within)
|
|
183
192
|
|
|
184
193
|
while name && !seen.include?(name)
|
|
185
194
|
seen << name
|
|
195
|
+
depth += 1
|
|
186
196
|
info = controllers[name]
|
|
187
197
|
source = info.is_a?(Hash) ? nil : base_controller_source(name, root)
|
|
188
198
|
info ||= { filters: Introspectors::ControllerFilters.from_source(source) } if source
|
|
@@ -201,29 +211,56 @@ module RailsAiContext
|
|
|
201
211
|
# the walk did see is never mistaken for a skip of a declaration it
|
|
202
212
|
# could not. A skip record is not a sighting, hence the reject above.
|
|
203
213
|
declares.merge(carried.map { |f| f[:name].to_s })
|
|
214
|
+
# Whether this class's own body was read at all. Without that, an
|
|
215
|
+
# unmarked filter is a filter nobody could check, not one a gem
|
|
216
|
+
# installed - an engine's ApplicationController and a concern-only
|
|
217
|
+
# payload both land there, and dropping their attribution would lose
|
|
218
|
+
# the answer rather than correct it.
|
|
219
|
+
body_known = carried.any? { |f| f[:declared] }
|
|
204
220
|
carried.select { |f| applies?(f, action) }
|
|
205
221
|
.reject { |f| dropped.include?(f[:name].to_s) }
|
|
206
|
-
.each { |f| record_attribution(found, attributed, f, name) }
|
|
222
|
+
.each { |f| record_attribution(found, attributed, f, name, positions, depth, evidence, body_known) }
|
|
207
223
|
|
|
208
224
|
name = Introspectors::ActionResolver.resolve_entry_name(controllers, info[:parent_class], name)
|
|
209
225
|
end
|
|
210
226
|
|
|
211
|
-
[ found
|
|
227
|
+
[ run_order(found, attributed, positions, evidence), dropped, conditions, declares ]
|
|
212
228
|
end
|
|
213
229
|
|
|
214
230
|
# The closest ancestor carrying a filter keeps its constraints, but a
|
|
215
231
|
# booted ancestor carries names it only inherits, so `from:` moves on to
|
|
216
232
|
# the first ancestor whose own body declared it.
|
|
217
|
-
def record_attribution(found, attributed, filter, ancestor
|
|
233
|
+
def record_attribution(found, attributed, filter, ancestor, positions = {}, depth = 0,
|
|
234
|
+
evidence = {}, body_known = false)
|
|
218
235
|
key = entry_key(filter)
|
|
219
236
|
if found.key?(key)
|
|
220
237
|
found[key] = found[key].merge(from: ancestor) if filter[:declared] && !attributed.include?(key)
|
|
221
238
|
else
|
|
222
239
|
found[key] = filter.merge(from: ancestor)
|
|
240
|
+
positions[key] = depth
|
|
241
|
+
evidence[key] = body_known
|
|
223
242
|
end
|
|
224
243
|
attributed << key if filter[:declared]
|
|
225
244
|
end
|
|
226
245
|
|
|
246
|
+
# Rails runs the root's callbacks first, so the inherited list reads that
|
|
247
|
+
# way: the walk's class order reversed, each class's own order kept. A
|
|
248
|
+
# filter no ancestor's body declares is installed from somewhere else -
|
|
249
|
+
# a gem's `on_load :action_controller` block, the framework, a concern -
|
|
250
|
+
# and crediting it to the nearest app class sent an agent to a file that
|
|
251
|
+
# never mentions it.
|
|
252
|
+
def run_order(found, attributed, positions, evidence = {})
|
|
253
|
+
found.keys
|
|
254
|
+
.each_with_index
|
|
255
|
+
.sort_by { |key, index| [ -positions.fetch(key, 0), index ] }
|
|
256
|
+
.map do |key, _|
|
|
257
|
+
entry = found[key]
|
|
258
|
+
next entry if attributed.include?(key) || !evidence[key]
|
|
259
|
+
|
|
260
|
+
entry.merge(from: nil, provenance: "not declared in the controller chain").compact
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
227
264
|
# One entry in the chain. A skip names a filter by name, and a chain entry
|
|
228
265
|
# is a kind and a name: a class can declare both `before_action :audit` and
|
|
229
266
|
# `after_action :audit`, and both run.
|
|
@@ -13,7 +13,7 @@ module RailsAiContext
|
|
|
13
13
|
|
|
14
14
|
# All YAML-supported keys (explicit allowlist for safety)
|
|
15
15
|
YAML_KEYS = %i[
|
|
16
|
-
ai_tools tool_mode preset context_mode generate_root_files claude_max_lines
|
|
16
|
+
ai_tools tool_mode preset context_mode context_files generate_root_files claude_max_lines
|
|
17
17
|
anti_hallucination_rules
|
|
18
18
|
server_name cache_ttl max_tool_response_chars
|
|
19
19
|
live_reload live_reload_debounce auto_mount http_path http_bind http_port
|
|
@@ -256,6 +256,12 @@ module RailsAiContext
|
|
|
256
256
|
# for the security review that changed the default.
|
|
257
257
|
attr_accessor :instrumentation_include_arguments
|
|
258
258
|
|
|
259
|
+
# Whether this gem writes context files at all. False is MCP-only: the
|
|
260
|
+
# server and the CLI still answer, and no CLAUDE.md, AGENTS.md, rules
|
|
261
|
+
# file or .ai-context.json is written or touched. Narrower than
|
|
262
|
+
# `generate_root_files`, which still writes the split rule files.
|
|
263
|
+
attr_accessor :context_files
|
|
264
|
+
|
|
259
265
|
# Whether to generate root-level context files (CLAUDE.md, AGENTS.md, etc.)
|
|
260
266
|
# When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
|
|
261
267
|
attr_accessor :generate_root_files
|
|
@@ -408,6 +414,7 @@ module RailsAiContext
|
|
|
408
414
|
@live_reload = :auto
|
|
409
415
|
@live_reload_debounce = 1.5
|
|
410
416
|
@instrumentation_include_arguments = false
|
|
417
|
+
@context_files = true
|
|
411
418
|
@generate_root_files = true
|
|
412
419
|
@anti_hallucination_rules = true
|
|
413
420
|
@max_file_size = 5_000_000
|
|
@@ -197,6 +197,10 @@ module RailsAiContext
|
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
def check_context_freshness
|
|
200
|
+
# An MCP-only install asked for no context files, so their absence is
|
|
201
|
+
# the configuration working, not something to fix.
|
|
202
|
+
return nil unless RailsAiContext.configuration.context_files
|
|
203
|
+
|
|
200
204
|
ai_tools = configured_ai_tools
|
|
201
205
|
|
|
202
206
|
# Find the first existing context file or split rule directory for configured tools
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# The Ruby inside ERB tags. Two readers need it for different reasons - a
|
|
5
|
+
# template's ivars, and the ENV names a `.yml` or a view reads - and a
|
|
6
|
+
# second copy of the tag regex is a second answer to "what is a tag".
|
|
7
|
+
module ErbSource
|
|
8
|
+
TAG = /<%={0,2}-?(.*?)-?%>/m
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @return [Boolean] whether the source carries any ERB tag at all
|
|
13
|
+
def tagged?(source)
|
|
14
|
+
source.to_s.include?("<%")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The tag bodies, joined. Order is kept; line numbers are not.
|
|
18
|
+
def tag_bodies(source)
|
|
19
|
+
source.to_s.scan(TAG).flatten.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The same Ruby with everything outside the tags blanked rather than
|
|
23
|
+
# dropped, so a line number in the result is still the line in the file.
|
|
24
|
+
# A `<%#` comment is blanked too.
|
|
25
|
+
def ruby_in_place(source)
|
|
26
|
+
text = source.to_s
|
|
27
|
+
out = +""
|
|
28
|
+
last = 0
|
|
29
|
+
text.to_enum(:scan, TAG).each do
|
|
30
|
+
match = Regexp.last_match
|
|
31
|
+
out << blank(text[last...match.begin(0)])
|
|
32
|
+
body = match[1].to_s
|
|
33
|
+
out << (body.lstrip.start_with?("#") ? blank(body) : body)
|
|
34
|
+
last = match.end(0)
|
|
35
|
+
end
|
|
36
|
+
out << blank(text[last..].to_s)
|
|
37
|
+
out
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def blank(text)
|
|
41
|
+
text.gsub(/[^\n]/, " ")
|
|
42
|
+
end
|
|
43
|
+
private_class_method :blank
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
3
4
|
require_relative "safe_file"
|
|
4
5
|
|
|
5
6
|
module RailsAiContext
|
|
@@ -13,6 +14,7 @@ module RailsAiContext
|
|
|
13
14
|
module GemLock
|
|
14
15
|
MAX_SIZE = 5 * 1024 * 1024
|
|
15
16
|
SPEC_LINE = /\A {4}(\S+) \(([^)]+)\)\s*\z/
|
|
17
|
+
DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?\s*\z/
|
|
16
18
|
RUBY_LINE = /\A\s+ruby (\S+)/
|
|
17
19
|
GEMFILE_RUBY_LINE = /^\s*ruby\s+(["'])([^"']+)\1/
|
|
18
20
|
PLAIN_VERSION = /\A\d+(?:\.\d+)*\S*\z/
|
|
@@ -20,11 +22,12 @@ module RailsAiContext
|
|
|
20
22
|
class Spec
|
|
21
23
|
attr_reader :ruby_version, :reason
|
|
22
24
|
|
|
23
|
-
def initialize(versions, ruby_version: nil, reason: nil, absent: false)
|
|
25
|
+
def initialize(versions, ruby_version: nil, reason: nil, absent: false, direct: nil)
|
|
24
26
|
@versions = versions
|
|
25
27
|
@ruby_version = ruby_version
|
|
26
28
|
@reason = reason
|
|
27
29
|
@absent = absent
|
|
30
|
+
@direct = direct || Set.new
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
# No lockfile, and a lockfile that named no gem, are both "the app's
|
|
@@ -49,6 +52,14 @@ module RailsAiContext
|
|
|
49
52
|
@versions[name.to_s]
|
|
50
53
|
end
|
|
51
54
|
|
|
55
|
+
# Named in the Gemfile, as opposed to resolved as some other gem's
|
|
56
|
+
# dependency. Every Rails app resolves minitest through activesupport,
|
|
57
|
+
# and reporting that as the app's test framework sent agents to a
|
|
58
|
+
# `test/` directory that does not exist.
|
|
59
|
+
def direct?(name)
|
|
60
|
+
@direct.include?(name.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
52
63
|
def any?(*names)
|
|
53
64
|
names.flatten.any? { |name| present?(name) }
|
|
54
65
|
end
|
|
@@ -99,12 +110,17 @@ module RailsAiContext
|
|
|
99
110
|
return Spec.new({}, reason: "Gemfile.lock could not be read") unless content
|
|
100
111
|
|
|
101
112
|
versions = {}
|
|
113
|
+
direct = Set.new
|
|
102
114
|
ruby_version = nil
|
|
103
115
|
in_specs = false
|
|
116
|
+
in_dependencies = false
|
|
104
117
|
specs_section = false
|
|
105
118
|
content.each_line do |line|
|
|
106
119
|
if line.match?(/\A\S/)
|
|
107
120
|
in_specs = false
|
|
121
|
+
in_dependencies = line.start_with?("DEPENDENCIES")
|
|
122
|
+
elsif in_dependencies && (match = line.match(DEPENDENCY_LINE))
|
|
123
|
+
direct << match[1]
|
|
108
124
|
elsif line.strip == "specs:"
|
|
109
125
|
in_specs = true
|
|
110
126
|
specs_section = true
|
|
@@ -122,7 +138,7 @@ module RailsAiContext
|
|
|
122
138
|
# and answering it as an app with no gems denies every gem it holds.
|
|
123
139
|
return Spec.new({}, reason: "Gemfile.lock has no specs section") unless specs_section
|
|
124
140
|
|
|
125
|
-
Spec.new(versions, ruby_version: ruby_version || gemfile_ruby_version(gemfile))
|
|
141
|
+
Spec.new(versions, ruby_version: ruby_version || gemfile_ruby_version(gemfile), direct: direct)
|
|
126
142
|
end
|
|
127
143
|
private_class_method :parse
|
|
128
144
|
|
|
@@ -14,8 +14,7 @@ module RailsAiContext
|
|
|
14
14
|
model_names = detect_model_references(source_path)
|
|
15
15
|
return HydrationResult.new if model_names.empty?
|
|
16
16
|
|
|
17
|
-
ModelHints.resolve(model_names, context: context
|
|
18
|
-
describe: ->(name) { "Model '#{name}' referenced but not found in introspection data" })
|
|
17
|
+
ModelHints.resolve(model_names, context: context)
|
|
19
18
|
rescue => e
|
|
20
19
|
$stderr.puts "[rails-ai-context] ControllerHydrator failed: #{e.message}" if ENV["DEBUG"]
|
|
21
20
|
HydrationResult.new
|
|
@@ -9,7 +9,10 @@ module RailsAiContext
|
|
|
9
9
|
module ModelHints
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# `describe` nil means a name that resolves to no model is simply not a
|
|
13
|
+
# model: a controller names services, serializers and plain constants,
|
|
14
|
+
# and every one of them read as a model the payload had lost.
|
|
15
|
+
def resolve(names, context:, describe: nil)
|
|
13
16
|
max = RailsAiContext.configuration.hydration_max_hints
|
|
14
17
|
hints = {}
|
|
15
18
|
warnings = []
|
|
@@ -17,7 +20,7 @@ module RailsAiContext
|
|
|
17
20
|
hint = SchemaHintBuilder.build(name, context: context)
|
|
18
21
|
if hint
|
|
19
22
|
hints[hint.model_name] ||= hint
|
|
20
|
-
|
|
23
|
+
elsif describe
|
|
21
24
|
warnings << describe.call(name)
|
|
22
25
|
end
|
|
23
26
|
end
|
|
@@ -49,18 +49,40 @@ module RailsAiContext
|
|
|
49
49
|
selected
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
# What this install writes. Answers 1 and 2 keep the meaning they have
|
|
53
|
+
# always had, so anything piping input into the installer still works.
|
|
54
|
+
Setup = Struct.new(:tool_mode, :context_files)
|
|
55
|
+
|
|
56
|
+
def select_setup(surface)
|
|
53
57
|
surface.say ""
|
|
54
|
-
surface.say "
|
|
58
|
+
surface.say "What should rails-ai-context write?", :emph
|
|
55
59
|
surface.say ""
|
|
56
|
-
surface.say " 1.
|
|
57
|
-
surface.say " 2.
|
|
60
|
+
surface.say " 1. MCP config + context files (default)"
|
|
61
|
+
surface.say " 2. Context files only (CLI mode, no MCP server)"
|
|
62
|
+
surface.say " 3. MCP config only (leaves CLAUDE.md, AGENTS.md and rules untouched)"
|
|
58
63
|
surface.say ""
|
|
59
64
|
|
|
60
65
|
input = surface.ask("Enter number (default: 1):").to_s.strip
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
setup = case input
|
|
67
|
+
when "2" then Setup.new(:cli, true)
|
|
68
|
+
when "3" then Setup.new(:mcp, false)
|
|
69
|
+
else Setup.new(:mcp, true)
|
|
70
|
+
end
|
|
71
|
+
surface.say "Selected: #{setup_label(setup)}", :ok
|
|
72
|
+
setup
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The tool-mode half of the same question, for a caller that predates
|
|
76
|
+
# the setup struct. It cannot express "MCP config only", so every
|
|
77
|
+
# entry point in this gem calls `select_setup` instead.
|
|
78
|
+
def select_tool_mode(surface)
|
|
79
|
+
select_setup(surface).tool_mode
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def setup_label(setup)
|
|
83
|
+
return "MCP config only (no context files)" unless setup.context_files
|
|
84
|
+
|
|
85
|
+
setup.tool_mode == :mcp ? "MCP + CLI fallback" : "CLI only"
|
|
64
86
|
end
|
|
65
87
|
|
|
66
88
|
# Offers to remove what a re-run dropped from the selection. `keeping:`
|
|
@@ -107,13 +129,15 @@ module RailsAiContext
|
|
|
107
129
|
end
|
|
108
130
|
end
|
|
109
131
|
|
|
110
|
-
|
|
132
|
+
# `context_files: false` never produces a .ai-context.json, so there is
|
|
133
|
+
# nothing to ignore.
|
|
134
|
+
def mark_gitignore(surface, root:, context_files: true)
|
|
111
135
|
gitignore = File.join(root.to_s, ".gitignore")
|
|
112
136
|
return unless File.exist?(gitignore)
|
|
113
137
|
|
|
114
138
|
content = File.read(gitignore)
|
|
115
139
|
lines = []
|
|
116
|
-
|
|
140
|
+
if context_files && !content.include?(".ai-context.json")
|
|
117
141
|
lines << "" << "# rails-ai-context (JSON cache - markdown files should be committed)" << ".ai-context.json"
|
|
118
142
|
end
|
|
119
143
|
unless content.include?(".codex/config.toml")
|
|
@@ -36,6 +36,10 @@ module RailsAiContext
|
|
|
36
36
|
|
|
37
37
|
MODE_LINE = /^[ \t]*config\.tool_mode\s*=\s*:(\w+)/
|
|
38
38
|
|
|
39
|
+
# Only an uncommented assignment: the generated initializer ships the
|
|
40
|
+
# key commented out, and a comment is not a choice.
|
|
41
|
+
CONTEXT_FILES_LINE = /^[ \t]*config\.context_files\s*=\s*(true|false)/
|
|
42
|
+
|
|
39
43
|
# Where a selection line goes when the initializer has none yet.
|
|
40
44
|
CONFIGURE_BLOCK = /RailsAiContext\.configure do \|config\|\n/
|
|
41
45
|
|
|
@@ -56,6 +60,23 @@ module RailsAiContext
|
|
|
56
60
|
mode_from_initializer(root) || mode_from_yaml(root)
|
|
57
61
|
end
|
|
58
62
|
|
|
63
|
+
# Whether this install writes context files, initializer first, the
|
|
64
|
+
# same precedence the tools and the mode keep.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean, nil] nil when nothing recorded it
|
|
67
|
+
def context_files(root:)
|
|
68
|
+
value = context_files_from_initializer(root)
|
|
69
|
+
return value unless value.nil?
|
|
70
|
+
|
|
71
|
+
context_files_from_yaml(root)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Symbol] :updated, :inserted, :unchanged or :absent
|
|
75
|
+
def write_context_files(value, root:)
|
|
76
|
+
write_config_line(root, " config.context_files = #{value ? 'true' : 'false'}",
|
|
77
|
+
CONTEXT_FILES_LINE, /^[ \t]*config\.context_files\s*=.*$/)
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
# Rewrites (or inserts) the tool_mode line, the way `write` handles the
|
|
60
81
|
# tools line. The rake task carried its own three-branch rewriter with a
|
|
61
82
|
# duplicate of CONFIGURE_BLOCK; the file's shape belongs here. Only an
|
|
@@ -184,6 +205,57 @@ module RailsAiContext
|
|
|
184
205
|
nil
|
|
185
206
|
end
|
|
186
207
|
|
|
208
|
+
# One rewriter for the single-value keys this record owns, because the
|
|
209
|
+
# three branches (rewrite, insert beside the selection, insert into the
|
|
210
|
+
# configure block) are the file's shape, not the key's.
|
|
211
|
+
private_class_method def self.write_config_line(root, line, present, assignment)
|
|
212
|
+
path = File.join(root.to_s, INITIALIZER)
|
|
213
|
+
return :absent unless File.exist?(path)
|
|
214
|
+
|
|
215
|
+
content = File.read(path)
|
|
216
|
+
if content.match?(present)
|
|
217
|
+
updated = content.sub(assignment, line)
|
|
218
|
+
return :unchanged if updated == content
|
|
219
|
+
|
|
220
|
+
File.write(path, updated)
|
|
221
|
+
:updated
|
|
222
|
+
elsif content.match?(SELECTION_LINE)
|
|
223
|
+
File.write(path, content.sub(/^([ \t]*config\.ai_tools\s*=[^\n]*)$/) { "#{Regexp.last_match(1)}\n#{line}" })
|
|
224
|
+
:inserted
|
|
225
|
+
elsif content.match?(CONFIGURE_BLOCK)
|
|
226
|
+
File.write(path, content.sub(CONFIGURE_BLOCK) { "#{Regexp.last_match(0)}#{line}\n" })
|
|
227
|
+
:inserted
|
|
228
|
+
else
|
|
229
|
+
:absent
|
|
230
|
+
end
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
RailsAiContext.log_warn "[rails-ai-context] could not write #{INITIALIZER}: #{e.message}"
|
|
233
|
+
:unchanged
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private_class_method def self.context_files_from_initializer(root)
|
|
237
|
+
path = File.join(root.to_s, INITIALIZER)
|
|
238
|
+
return nil unless File.exist?(path)
|
|
239
|
+
|
|
240
|
+
match = File.read(path)[CONTEXT_FILES_LINE, 1]
|
|
241
|
+
match.nil? ? nil : match == "true"
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
RailsAiContext.log_warn "[rails-ai-context] could not read #{INITIALIZER}: #{e.message}" if ENV["DEBUG"]
|
|
244
|
+
nil
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private_class_method def self.context_files_from_yaml(root)
|
|
248
|
+
path = File.join(root.to_s, YAML_FILE)
|
|
249
|
+
return nil unless File.exist?(path)
|
|
250
|
+
|
|
251
|
+
data = YAML.safe_load_file(path, permitted_classes: PERMITTED_YAML) || {}
|
|
252
|
+
value = data["context_files"]
|
|
253
|
+
value.nil? ? nil : !!value
|
|
254
|
+
rescue StandardError => e
|
|
255
|
+
RailsAiContext.log_warn "[rails-ai-context] could not read #{YAML_FILE}: #{e.message}" if ENV["DEBUG"]
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
187
259
|
private_class_method def self.mode_from_initializer(root)
|
|
188
260
|
path = File.join(root.to_s, INITIALIZER)
|
|
189
261
|
return nil unless File.exist?(path)
|
|
@@ -71,6 +71,15 @@ module RailsAiContext
|
|
|
71
71
|
hits = ast[:concern_macros]
|
|
72
72
|
|
|
73
73
|
entry = { name: mod_name, file: path.sub("#{root}/", "") }
|
|
74
|
+
# An app/models/concerns directory holds classes too - every
|
|
75
|
+
# validator in some apps - and a class is not a concern, let
|
|
76
|
+
# alone a "plain module".
|
|
77
|
+
declarations = DeclaredConstant.declarations(content)
|
|
78
|
+
declared = declarations.find { |d| d.name.split("::").last.casecmp?(mod_name) }
|
|
79
|
+
if declared
|
|
80
|
+
entry[:kind] = "class"
|
|
81
|
+
entry[:superclass] = declared.superclass
|
|
82
|
+
end
|
|
74
83
|
entry[:uses_active_support_concern] = true if content.include?("ActiveSupport::Concern")
|
|
75
84
|
entry[:included_blocks] = hits.count { |h| h[:macro] == :included }
|
|
76
85
|
entry[:class_methods_block] = hits.any? { |h| h[:macro] == :class_methods }
|
|
@@ -65,9 +65,30 @@ module RailsAiContext
|
|
|
65
65
|
.map { |record| DeclaredConstant.resolve(record.source, record.path_name) }.uniq.sort
|
|
66
66
|
result[:serializer_classes] = names if names.any?
|
|
67
67
|
|
|
68
|
+
# An app that keeps its own serializer layer somewhere else still has
|
|
69
|
+
# one, and "none detected" pushed an agent to add jbuilder or a second
|
|
70
|
+
# layer under app/serializers.
|
|
71
|
+
other = other_serializer_dirs
|
|
72
|
+
result[:serializer_dirs] = other if other.any?
|
|
73
|
+
|
|
68
74
|
result
|
|
69
75
|
end
|
|
70
76
|
|
|
77
|
+
# Any `serializers` directory under app/ other than app/serializers
|
|
78
|
+
# itself: `app/services/serializers/...` is the shape this missed.
|
|
79
|
+
def other_serializer_dirs
|
|
80
|
+
Dir.glob(File.join(root, "app", "**", "serializers"))
|
|
81
|
+
.select { |path| File.directory?(path) }
|
|
82
|
+
.map { |path| path.sub("#{root}/", "") }
|
|
83
|
+
.reject { |relative| relative == "app/serializers" }
|
|
84
|
+
.sort
|
|
85
|
+
.map { |relative| { path: relative, files: Dir.glob(File.join(root, relative, "**", "*.rb")).size } }
|
|
86
|
+
.reject { |entry| entry[:files].zero? }
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
$stderr.puts "[rails-ai-context] other_serializer_dirs failed: #{e.message}" if ENV["DEBUG"]
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
def detect_graphql
|
|
72
93
|
graphql_dir = File.join(root, "app/graphql")
|
|
73
94
|
return nil unless Dir.exist?(graphql_dir)
|
|
@@ -103,25 +124,83 @@ module RailsAiContext
|
|
|
103
124
|
[]
|
|
104
125
|
end
|
|
105
126
|
|
|
127
|
+
# Per `allow` block, because that is the unit rack-cors applies: one
|
|
128
|
+
# flat origin list read as though every origin reached every resource,
|
|
129
|
+
# and an environment branch read as though all of its arms were live at
|
|
130
|
+
# once.
|
|
106
131
|
def detect_cors_config
|
|
107
132
|
cors_path = File.join(root, "config/initializers/cors.rb")
|
|
108
133
|
return nil unless File.exist?(cors_path)
|
|
109
134
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
origin_calls = ast_data[:origins]
|
|
115
|
-
return nil if origin_calls.empty?
|
|
135
|
+
source = RailsAiContext::SafeFile.read(cors_path)
|
|
136
|
+
node = source && AstCache.parse_string(source)&.value
|
|
137
|
+
return nil unless node
|
|
116
138
|
|
|
117
|
-
|
|
139
|
+
allows = []
|
|
140
|
+
collect_allow_blocks(node, allows)
|
|
141
|
+
origins = allows.flat_map { |allow| allow[:origins].map { |o| o[:value] } }.uniq
|
|
142
|
+
return nil if origins.empty?
|
|
118
143
|
|
|
119
|
-
{ file: "config/initializers/cors.rb", origins: origins }
|
|
144
|
+
{ file: "config/initializers/cors.rb", origins: origins, allows: allows }
|
|
120
145
|
rescue => e
|
|
121
146
|
$stderr.puts "[rails-ai-context] detect_cors_config failed: #{e.message}" if ENV["DEBUG"]
|
|
122
147
|
nil
|
|
123
148
|
end
|
|
124
149
|
|
|
150
|
+
def collect_allow_blocks(node, found)
|
|
151
|
+
return unless node.is_a?(Prism::Node)
|
|
152
|
+
|
|
153
|
+
if node.is_a?(Prism::CallNode) && node.name == :allow && node.block
|
|
154
|
+
entry = { origins: [], resources: [] }
|
|
155
|
+
collect_cors_calls(node.block, entry, nil)
|
|
156
|
+
found << entry if entry[:origins].any? || entry[:resources].any?
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
node.compact_child_nodes.each { |child| collect_allow_blocks(child, found) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def collect_cors_calls(node, entry, condition)
|
|
164
|
+
return unless node.is_a?(Prism::Node)
|
|
165
|
+
|
|
166
|
+
case node
|
|
167
|
+
when Prism::IfNode
|
|
168
|
+
predicate = one_line_source(node.predicate)
|
|
169
|
+
collect_cors_calls(node.statements, entry, predicate)
|
|
170
|
+
collect_cors_calls(node.subsequent, entry, "else #{predicate}")
|
|
171
|
+
return
|
|
172
|
+
when Prism::UnlessNode
|
|
173
|
+
predicate = "not #{one_line_source(node.predicate)}"
|
|
174
|
+
collect_cors_calls(node.statements, entry, predicate)
|
|
175
|
+
collect_cors_calls(node.else_clause, entry, "else #{predicate}")
|
|
176
|
+
return
|
|
177
|
+
when Prism::CallNode
|
|
178
|
+
if node.receiver.nil? && %i[origins resource].include?(node.name)
|
|
179
|
+
values = Array(node.arguments&.arguments).flat_map { |arg| literal_strings(arg) }
|
|
180
|
+
if node.name == :origins
|
|
181
|
+
values.each { |value| entry[:origins] << { value: value, condition: condition }.compact }
|
|
182
|
+
else
|
|
183
|
+
entry[:resources] << values.first if values.first
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
node.compact_child_nodes.each { |child| collect_cors_calls(child, entry, condition) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def literal_strings(node)
|
|
192
|
+
case node
|
|
193
|
+
when Prism::StringNode then [ node.unescaped ]
|
|
194
|
+
when Prism::SymbolNode then [ node.value.to_s ]
|
|
195
|
+
when Prism::ArrayNode then node.elements.flat_map { |element| literal_strings(element) }
|
|
196
|
+
else []
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def one_line_source(node)
|
|
201
|
+
node ? node.slice.gsub(/\s+/, " ").strip : nil
|
|
202
|
+
end
|
|
203
|
+
|
|
125
204
|
def detect_api_client_generation
|
|
126
205
|
package_path = File.join(root, "package.json")
|
|
127
206
|
return [] unless File.exist?(package_path)
|
|
@@ -200,7 +200,10 @@ module RailsAiContext
|
|
|
200
200
|
spec test
|
|
201
201
|
]
|
|
202
202
|
|
|
203
|
-
|
|
203
|
+
# Plus whatever else the app keeps under app/. A fixed list left
|
|
204
|
+
# `app/workers` - 516 files on one app - out of the only section that
|
|
205
|
+
# carries file counts, so the biggest code area had no number anywhere.
|
|
206
|
+
(important_dirs | app_child_dirs).each_with_object({}) do |dir, hash|
|
|
204
207
|
count = SourceScan.paths(root, kind: dir, skip_concerns: false).count
|
|
205
208
|
count += Dir.glob(File.join(root, dir, "**/*.js")).size if dir.include?("javascript")
|
|
206
209
|
|
|
@@ -208,6 +211,19 @@ module RailsAiContext
|
|
|
208
211
|
end
|
|
209
212
|
end
|
|
210
213
|
|
|
214
|
+
def app_child_dirs
|
|
215
|
+
app_dir = File.join(root, "app")
|
|
216
|
+
return [] unless Dir.exist?(app_dir)
|
|
217
|
+
|
|
218
|
+
Dir.children(app_dir)
|
|
219
|
+
.select { |child| File.directory?(File.join(app_dir, child)) }
|
|
220
|
+
.map { |child| "app/#{child}" }
|
|
221
|
+
.sort
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
$stderr.puts "[rails-ai-context] app_child_dirs failed: #{e.message}" if ENV["DEBUG"]
|
|
224
|
+
[]
|
|
225
|
+
end
|
|
226
|
+
|
|
211
227
|
def detect_config_files
|
|
212
228
|
configs = %w[
|
|
213
229
|
config/database.yml config/credentials.yml.enc
|