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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +3 -1
  4. data/docs/COMPATIBILITY.md +4 -1
  5. data/docs/CONFIGURATION.md +27 -0
  6. data/docs/FAQ.md +13 -0
  7. data/docs/INTROSPECTORS.md +1 -1
  8. data/docs/QUICKSTART.md +5 -1
  9. data/docs/SETUP.md +4 -0
  10. data/docs/STANDALONE.md +3 -0
  11. data/docs/TOOLS.md +12 -6
  12. data/exe/rails-ai-context +46 -16
  13. data/lib/generators/rails_ai_context/install/install_generator.rb +54 -11
  14. data/lib/rails_ai_context/action_filters.rb +42 -5
  15. data/lib/rails_ai_context/configuration.rb +8 -1
  16. data/lib/rails_ai_context/doctor.rb +4 -0
  17. data/lib/rails_ai_context/erb_source.rb +45 -0
  18. data/lib/rails_ai_context/gem_lock.rb +18 -2
  19. data/lib/rails_ai_context/hydrators/controller_hydrator.rb +1 -2
  20. data/lib/rails_ai_context/hydrators/model_hints.rb +5 -2
  21. data/lib/rails_ai_context/install/program.rb +33 -9
  22. data/lib/rails_ai_context/install/selection_record.rb +72 -0
  23. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +9 -0
  24. data/lib/rails_ai_context/introspectors/api_introspector.rb +87 -8
  25. data/lib/rails_ai_context/introspectors/convention_introspector.rb +17 -1
  26. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +47 -7
  27. data/lib/rails_ai_context/introspectors/gem_introspector.rb +58 -4
  28. data/lib/rails_ai_context/introspectors/job_introspector.rb +36 -4
  29. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +36 -0
  30. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +22 -4
  31. data/lib/rails_ai_context/introspectors/model_introspector.rb +56 -15
  32. data/lib/rails_ai_context/introspectors/performance_introspector.rb +70 -2
  33. data/lib/rails_ai_context/introspectors/route_introspector.rb +30 -13
  34. data/lib/rails_ai_context/introspectors/view_introspector.rb +1 -0
  35. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +16 -7
  36. data/lib/rails_ai_context/output_guard.rb +43 -3
  37. data/lib/rails_ai_context/tasks/rails_ai_context.rake +34 -8
  38. data/lib/rails_ai_context/tools/analyze_feature.rb +7 -1
  39. data/lib/rails_ai_context/tools/base_tool.rb +9 -0
  40. data/lib/rails_ai_context/tools/dependency_graph.rb +96 -15
  41. data/lib/rails_ai_context/tools/generate_test.rb +110 -14
  42. data/lib/rails_ai_context/tools/get_active_support.rb +5 -1
  43. data/lib/rails_ai_context/tools/get_api.rb +24 -0
  44. data/lib/rails_ai_context/tools/get_callbacks.rb +6 -6
  45. data/lib/rails_ai_context/tools/get_controllers.rb +33 -3
  46. data/lib/rails_ai_context/tools/get_env.rb +68 -31
  47. data/lib/rails_ai_context/tools/get_gems.rb +9 -2
  48. data/lib/rails_ai_context/tools/get_job_pattern.rb +25 -3
  49. data/lib/rails_ai_context/tools/get_model_details.rb +2 -1
  50. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -20
  51. data/lib/rails_ai_context/tools/get_routes.rb +6 -2
  52. data/lib/rails_ai_context/tools/get_service_pattern.rb +81 -20
  53. data/lib/rails_ai_context/tools/get_view.rb +9 -1
  54. data/lib/rails_ai_context/tools/onboard.rb +5 -0
  55. data/lib/rails_ai_context/tools/query.rb +6 -1
  56. data/lib/rails_ai_context/tools/search_code.rb +38 -7
  57. data/lib/rails_ai_context/tools/validate_semantics.rb +32 -1
  58. data/lib/rails_ai_context/version.rb +1 -1
  59. data/lib/rails_ai_context/vfs.rb +22 -2
  60. data/lib/rails_ai_context/view_file.rb +38 -0
  61. data/lib/rails_ai_context/watcher.rb +4 -1
  62. data/lib/rails_ai_context.rb +7 -1
  63. data/server.json +1 -1
  64. 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
- declared_on = parent.to_h { |f| [ entry_key(f), f[:from] ] }
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.merge(from: declared_on[entry_key(f)]) }, conditions, action
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.values, dropped, conditions, declares ]
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
- def resolve(names, context:, describe:)
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
- else
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
- def select_tool_mode(surface)
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 "Do you also want MCP server support?", :emph
58
+ surface.say "What should rails-ai-context write?", :emph
55
59
  surface.say ""
56
- surface.say " 1. Yes - MCP primary + CLI fallback (generates per-tool MCP config files)"
57
- surface.say " 2. No - CLI only (no server needed)"
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
- mode = input == "2" ? :cli : :mcp
62
- surface.say "Selected: #{mode == :mcp ? 'MCP + CLI fallback' : 'CLI only'}", :ok
63
- mode
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
- def mark_gitignore(surface, root:)
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
- unless content.include?(".ai-context.json")
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
- ast_data = SourceIntrospector.walk(cors_path, {
111
- origins: -> { Listeners::GenericMacroListener.new(:origins) }
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
- origins = origin_calls.flat_map { |macro| macro[:values].flatten.map(&:to_s) }
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
- important_dirs.each_with_object({}) do |dir, hash|
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