Module: RailsAiContext::Serializers::StackOverviewHelper

Overview

Shared helper for rendering stack overview lines from full-preset introspectors. Include in any serializer that has a context reader and renders a project overview.

Instance Method Summary collapse

Methods included from CountPhrase

call

Instance Method Details

#arch_labels_hashObject

Safely resolve architecture labels from GetConventions tool.



145
146
147
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 145

def arch_labels_hash
  RailsAiContext::Tools::GetConventions::ARCH_LABELS rescue {}
end

#database_adapter_label(_schema = nil) ⇒ Object

One seam for every surface that names the database, so the generated files, the tools and the rake task cannot answer differently for one app. See RailsAiContext::SchemaAdapter.



220
221
222
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 220

def database_adapter_label(_schema = nil)
  SchemaAdapter.label(context)
end

#detect_before_actionsObject

Extract before_action names from ApplicationController source.



208
209
210
211
212
213
214
215
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 208

def detect_before_actions
  app_ctrl_file = File.join(project_root, "app", "controllers", "application_controller.rb")
  return [] unless File.exist?(app_ctrl_file)
  File.read(app_ctrl_file).scan(/before_action\s+:([\w!?]+)/).flatten
rescue => e
  $stderr.puts "[rails-ai-context] Before actions scan skipped: #{e.message}" if ENV["DEBUG"]
  []
end

#detect_job_filesObject

Scan app/jobs/ for job class names.



196
197
198
199
200
201
202
203
204
205
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 196

def detect_job_files
  dir = File.join(project_root, "app", "jobs")
  return [] unless Dir.exist?(dir)
  Dir.glob(File.join(dir, "*.rb"))
    .map { |f| File.basename(f, ".rb").camelize }
    .reject { |j| j == "ApplicationJob" }
rescue => e
  $stderr.puts "[rails-ai-context] Job file scan skipped: #{e.message}" if ENV["DEBUG"]
  []
end

#detect_service_filesObject

Scan app/services/ for service object class names.



184
185
186
187
188
189
190
191
192
193
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 184

def detect_service_files
  dir = File.join(project_root, "app", "services")
  return [] unless Dir.exist?(dir)
  Dir.glob(File.join(dir, "*.rb"))
    .map { |f| File.basename(f, ".rb").camelize }
    .reject { |s| s == "ApplicationService" }
rescue => e
  $stderr.puts "[rails-ai-context] Service file scan skipped: #{e.message}" if ENV["DEBUG"]
  []
end

#full_preset_stack_lines(ctx = context) ⇒ Object

Returns an array of summary lines for full-preset introspectors. Each line is only added if the introspector returned meaningful data.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 12

def full_preset_stack_lines(ctx = context)
  lines = []

  auth = ctx[:auth]
  if auth.is_a?(Hash) && !auth[:error]
    parts = []
    parts << "Devise" if auth.dig(:authentication, :devise)&.any?
    parts << "Rails 8 auth" if auth.dig(:authentication, :rails_auth)
    parts << "Pundit" if auth.dig(:authorization, :pundit)&.any?
    parts << "CanCanCan" if auth.dig(:authorization, :cancancan)
    lines << "- Auth: #{parts.join(' + ')}" if parts.any?
  end

  turbo = ctx[:turbo]
  if turbo.is_a?(Hash) && !turbo[:error]
    parts = []
    parts << count_phrase((turbo[:frames] || []).size, "frame") if turbo[:frames]&.any?
    parts << count_phrase((turbo[:streams] || []).size, "stream") if turbo[:streams]&.any?
    parts << "broadcasts" if turbo[:broadcasts]&.any?
    lines << "- Hotwire: #{parts.join(', ')}" if parts.any?
  end

  api = ctx[:api]
  if api.is_a?(Hash) && !api[:error]
    parts = []
    parts << "API-only" if api[:api_only]
    parts << count_phrase((api[:versions] || []).size, "version") if api[:versions]&.any?
    parts << "GraphQL" if api[:graphql]&.any?
    parts << api[:serializer_library] if api[:serializer_library]
    lines << "- API: #{parts.join(', ')}" if parts.any?
  end

  i18n_data = ctx[:i18n]
  if i18n_data.is_a?(Hash) && !i18n_data[:error]
    locales = i18n_data[:available_locales] || []
    lines << "- I18n: #{count_phrase(locales.size, "locale")} (#{locales.first(5).join(', ')})" if locales.size > 1
  end

  storage = ctx[:active_storage]
  if storage.is_a?(Hash) && !storage[:error] && storage[:attachments]&.any?
    lines << "- Storage: ActiveStorage (#{count_phrase(storage[:attachments].size, "model")} with attachments)"
  end

  action_text = ctx[:action_text]
  if action_text.is_a?(Hash) && !action_text[:error] && action_text[:rich_text_fields]&.any?
    lines << "- RichText: ActionText (#{count_phrase(action_text[:rich_text_fields].size, "field")})"
  end

  assets = ctx[:assets]
  if assets.is_a?(Hash) && !assets[:error]
    parts = []
    parts << assets[:pipeline] if assets[:pipeline]
    parts << assets[:js_bundler] if assets[:js_bundler]
    parts << assets[:css_framework] if assets[:css_framework]
    lines << "- Assets: #{parts.join(', ')}" if parts.any?
  end

  engines = ctx[:engines]
  if engines.is_a?(Hash) && !engines[:error] && engines[:mounted]&.any?
    names = engines[:mounted].map { |e| e[:name] || e[:engine] }.compact.first(5)
    lines << "- Engines: #{names.join(', ')}" if names.any?
  end

  multi_db = ctx[:multi_database]
  if multi_db.is_a?(Hash) && !multi_db[:error] && multi_db[:databases]&.size.to_i > 1
    db_names = multi_db[:databases].is_a?(Array) ? multi_db[:databases].map { |d| d[:name] } : multi_db[:databases].keys
    lines << "- Databases: #{multi_db[:databases].size} (#{db_names.first(3).join(', ')})"
  end

  components = ctx[:components]
  if components.is_a?(Hash) && !components[:error] && components.dig(:summary, :total).to_i > 0
    summary = components[:summary]
    parts = [ count_phrase(summary[:total], "component") ]
    parts << count_phrase(summary[:view_component].to_i, "ViewComponent") if summary[:view_component].to_i > 0
    parts << "#{summary[:phlex]} Phlex" if summary[:phlex].to_i > 0
    lines << "- Components: #{parts.join(', ')}"
  end

  perf = ctx[:performance]
  if perf.is_a?(Hash) && !perf[:error] && perf[:summary]
    total = perf.dig(:summary, :total_issues).to_i
    lines << "- Performance: #{count_phrase(total, "issue")} detected" if total > 0
  end

  fe = ctx[:frontend_frameworks]
  if fe.is_a?(Hash) && !fe[:error]
    parts = []
    parts << "#{fe[:framework]} #{fe[:version]}".strip if fe[:framework]
    parts << fe[:mounting] if fe[:mounting]
    lines << "- Frontend: #{parts.join(', ')}" if parts.any?
  end

  lines
end

#model_extras_line(data) ⇒ Object

Render scopes and constants as a one-line extras summary for a model entry. Returns " scopes: a, b | STATUS: draft, active" or nil if no extras exist. Shared by cursor_rules, opencode_rules, copilot_instructions, compact_serializer_helper.



128
129
130
131
132
133
134
135
136
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 128

def model_extras_line(data)
  scopes = data[:scopes] || []
  constants = data[:constants] || []
  return nil unless scopes.any? || constants.any?
  extras = []
  extras << "scopes: #{scope_names(scopes).join(', ')}" if scopes.any?
  constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
  "  #{extras.join(' | ')}"
end

#notable_gems_list(gems_data) ⇒ Object

Extract notable gems with triple-fallback for varying introspector output shapes.



139
140
141
142
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 139

def notable_gems_list(gems_data)
  return [] unless gems_data.is_a?(Hash) && !gems_data[:error]
  gems_data[:notable_gems] || gems_data[:notable] || gems_data[:detected] || []
end

#pattern_labels_hashObject



149
150
151
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 149

def pattern_labels_hash
  RailsAiContext::Tools::GetConventions::PATTERN_LABELS rescue {}
end

#project_rootObject

Shared utility: resolve the project root directory. Used by serializers that scan app/ for services, jobs, controllers, etc.



179
180
181
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 179

def project_root
  defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.to_s : Dir.pwd
end

#render_compact_controllers_list(controllers_hash, limit: 25) ⇒ Object

Render a compact controllers listing: "- Name (N actions)" + "...X more". Shared by cursor_rules and copilot_instructions serializers.



114
115
116
117
118
119
120
121
122
123
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 114

def render_compact_controllers_list(controllers_hash, limit: 25)
  lines = []
  controllers_hash.keys.sort.first(limit).each do |name|
    info = controllers_hash[name]
    action_count = info[:actions]&.size || 0
    lines << "- #{name} (#{count_phrase(action_count, "action")})"
  end
  lines << "- ...#{controllers_hash.size - limit} more" if controllers_hash.size > limit
  lines
end

#scope_names(scopes) ⇒ Object

Extract scope names from scope data (handles both Hash and String forms).



108
109
110
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 108

def scope_names(scopes)
  scopes.map { |s| s.is_a?(Hash) ? s[:name] : s }
end

#write_rule_files(files) ⇒ Hash

Write split-rule files with diff-check and atomic writes.

Parameters:

  • files (Hash<String, String|nil>)

    filepath => content mapping

Returns:

  • (Hash)

    { written: [paths], skipped: [paths] }



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/rails_ai_context/serializers/stack_overview_helper.rb', line 156

def write_rule_files(files)
  written = []
  skipped = []

  files.each do |filepath, content|
    next unless content
    if File.exist?(filepath) && File.read(filepath) == content
      skipped << filepath
    else
      dir = File.dirname(filepath)
      FileUtils.mkdir_p(dir)
      tmp = File.join(dir, ".#{File.basename(filepath)}.#{SecureRandom.hex(4)}.tmp")
      File.write(tmp, content)
      File.rename(tmp, filepath)
      written << filepath
    end
  end

  { written: written, skipped: skipped }
end