rails-ai-context 0.1.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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +23 -0
  4. data/CHANGELOG.md +26 -0
  5. data/LICENSE +21 -0
  6. data/README.md +212 -0
  7. data/Rakefile +8 -0
  8. data/exe/rails-ai-context +67 -0
  9. data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
  10. data/lib/rails-ai-context.rb +5 -0
  11. data/lib/rails_ai_context/configuration.rb +52 -0
  12. data/lib/rails_ai_context/engine.rb +21 -0
  13. data/lib/rails_ai_context/introspector.rb +61 -0
  14. data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
  15. data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
  16. data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
  17. data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
  18. data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
  19. data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
  20. data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
  21. data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
  22. data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
  23. data/lib/rails_ai_context/server.rb +90 -0
  24. data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
  25. data/lib/rails_ai_context/tools/base_tool.rb +37 -0
  26. data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
  27. data/lib/rails_ai_context/tools/get_gems.rb +49 -0
  28. data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
  29. data/lib/rails_ai_context/tools/get_routes.rb +50 -0
  30. data/lib/rails_ai_context/tools/get_schema.rb +93 -0
  31. data/lib/rails_ai_context/tools/search_code.rb +111 -0
  32. data/lib/rails_ai_context/version.rb +5 -0
  33. data/lib/rails_ai_context.rb +74 -0
  34. data/rails-ai-context.gemspec +52 -0
  35. metadata +223 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Extracts database schema information including tables, columns,
6
+ # indexes, and foreign keys from the Rails application.
7
+ class SchemaIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ # @return [Hash] database schema context
15
+ def call
16
+ return static_schema_parse unless active_record_connected?
17
+
18
+ {
19
+ adapter: adapter_name,
20
+ tables: extract_tables,
21
+ total_tables: table_names.size,
22
+ schema_version: current_schema_version
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def active_record_connected?
29
+ defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
30
+ rescue
31
+ false
32
+ end
33
+
34
+ def adapter_name
35
+ ActiveRecord::Base.connection.adapter_name
36
+ rescue
37
+ "unknown"
38
+ end
39
+
40
+ def connection
41
+ ActiveRecord::Base.connection
42
+ end
43
+
44
+ def table_names
45
+ @table_names ||= connection.tables.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
46
+ end
47
+
48
+ def extract_tables
49
+ table_names.each_with_object({}) do |table, hash|
50
+ hash[table] = {
51
+ columns: extract_columns(table),
52
+ indexes: extract_indexes(table),
53
+ foreign_keys: extract_foreign_keys(table),
54
+ primary_key: connection.primary_key(table)
55
+ }
56
+ end
57
+ end
58
+
59
+ def extract_columns(table)
60
+ connection.columns(table).map do |col|
61
+ {
62
+ name: col.name,
63
+ type: col.type.to_s,
64
+ null: col.null,
65
+ default: col.default,
66
+ limit: col.limit,
67
+ precision: col.precision,
68
+ scale: col.scale,
69
+ comment: col.comment
70
+ }.compact
71
+ end
72
+ end
73
+
74
+ def extract_indexes(table)
75
+ connection.indexes(table).map do |idx|
76
+ {
77
+ name: idx.name,
78
+ columns: idx.columns,
79
+ unique: idx.unique,
80
+ where: idx.where
81
+ }.compact
82
+ end
83
+ end
84
+
85
+ def extract_foreign_keys(table)
86
+ connection.foreign_keys(table).map do |fk|
87
+ {
88
+ from_table: fk.from_table,
89
+ to_table: fk.to_table,
90
+ column: fk.column,
91
+ primary_key: fk.primary_key,
92
+ on_delete: fk.on_delete,
93
+ on_update: fk.on_update
94
+ }.compact
95
+ end
96
+ rescue
97
+ [] # Some adapters don't support foreign_keys
98
+ end
99
+
100
+ def current_schema_version
101
+ if File.exist?(schema_file_path)
102
+ content = File.read(schema_file_path)
103
+ match = content.match(/version:\s*(\d+)_?\d*/)
104
+ match ? match[1] : nil
105
+ end
106
+ end
107
+
108
+ def schema_file_path
109
+ File.join(app.root, "db", "schema.rb")
110
+ end
111
+
112
+ # Fallback: parse db/schema.rb as text when DB isn't connected
113
+ # This enables introspection in CI, Claude Code, etc.
114
+ def static_schema_parse
115
+ path = schema_file_path
116
+ return { error: "No schema.rb found at #{path}" } unless File.exist?(path)
117
+
118
+ content = File.read(path)
119
+ tables = {}
120
+ current_table = nil
121
+
122
+ content.each_line do |line|
123
+ if (match = line.match(/create_table\s+"(\w+)"/))
124
+ current_table = match[1]
125
+ next if current_table.start_with?("ar_internal_metadata", "schema_migrations")
126
+ tables[current_table] = { columns: [], indexes: [], foreign_keys: [] }
127
+ elsif current_table && (match = line.match(/t\.(\w+)\s+"(\w+)"/))
128
+ tables[current_table][:columns] << { name: match[2], type: match[1] }
129
+ elsif (match = line.match(/add_index\s+"(\w+)",\s+\[?"(\w+)"/))
130
+ tables[match[1]]&.dig(:indexes)&.push({ columns: match[2] })
131
+ end
132
+ end
133
+
134
+ {
135
+ adapter: "static_parse",
136
+ tables: tables,
137
+ total_tables: tables.size,
138
+ note: "Parsed from db/schema.rb (no DB connection)"
139
+ }
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Serializers
5
+ # Orchestrates writing context files to disk in various formats.
6
+ # Supports: CLAUDE.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md, JSON
7
+ class ContextFileSerializer
8
+ attr_reader :context, :format
9
+
10
+ FORMAT_MAP = {
11
+ claude: "CLAUDE.md",
12
+ cursor: ".cursorrules",
13
+ windsurf: ".windsurfrules",
14
+ copilot: ".github/copilot-instructions.md",
15
+ json: ".ai-context.json"
16
+ }.freeze
17
+
18
+ def initialize(context, format: :all)
19
+ @context = context
20
+ @format = format
21
+ end
22
+
23
+ # Write context files and return list of files written
24
+ # @return [Array<String>] file paths written
25
+ def call
26
+ formats = format == :all ? FORMAT_MAP.keys : Array(format)
27
+ output_dir = RailsAiContext.configuration.output_dir_for(Rails.application)
28
+ written = []
29
+
30
+ formats.each do |fmt|
31
+ filename = FORMAT_MAP[fmt]
32
+ next unless filename
33
+
34
+ filepath = File.join(output_dir, filename)
35
+
36
+ # Ensure subdirectory exists (e.g. .github/)
37
+ FileUtils.mkdir_p(File.dirname(filepath))
38
+
39
+ content = serialize(fmt)
40
+ File.write(filepath, content)
41
+ written << filepath
42
+ end
43
+
44
+ written
45
+ end
46
+
47
+ private
48
+
49
+ def serialize(fmt)
50
+ case fmt
51
+ when :json
52
+ JsonSerializer.new(context).call
53
+ else
54
+ # All markdown-based formats use the same content
55
+ MarkdownSerializer.new(context).call
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RailsAiContext
6
+ module Serializers
7
+ class JsonSerializer
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def call
15
+ JSON.pretty_generate(context)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Serializers
5
+ # Generates AI-friendly markdown context files from introspection data.
6
+ # Outputs: CLAUDE.md (for Claude Code), .cursorrules, .windsurfrules, etc.
7
+ class MarkdownSerializer
8
+ attr_reader :context
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ # @return [String] full markdown document
15
+ def call
16
+ sections = []
17
+ sections << header
18
+ sections << app_overview
19
+ sections << schema_section if context[:schema]
20
+ sections << models_section if context[:models]
21
+ sections << routes_section if context[:routes]
22
+ sections << jobs_section if context[:jobs]
23
+ sections << gems_section if context[:gems]
24
+ sections << conventions_section if context[:conventions]
25
+ sections << footer
26
+ sections.compact.join("\n\n")
27
+ end
28
+
29
+ private
30
+
31
+ def header
32
+ <<~MD
33
+ # #{context[:app_name]} — AI Context
34
+
35
+ > Auto-generated by rails-ai-context v#{RailsAiContext::VERSION}
36
+ > Generated: #{context[:generated_at]}
37
+ > Rails #{context[:rails_version]} | Ruby #{context[:ruby_version]}
38
+
39
+ This file gives AI assistants (Claude Code, Cursor, Copilot) deep context
40
+ about this Rails application's structure, patterns, and conventions.
41
+ MD
42
+ end
43
+
44
+ def app_overview
45
+ conv = context[:conventions] || {}
46
+ arch = conv[:architecture] || []
47
+ patterns = conv[:patterns] || []
48
+
49
+ lines = ["## Overview"]
50
+ lines << "- **Architecture:** #{arch.join(', ')}" if arch.any?
51
+ lines << "- **Patterns:** #{patterns.join(', ')}" if patterns.any?
52
+ lines.join("\n")
53
+ end
54
+
55
+ def schema_section
56
+ schema = context[:schema]
57
+ return if schema[:error]
58
+
59
+ lines = ["## Database Schema (#{schema[:total_tables]} tables)"]
60
+ schema[:tables]&.each do |name, data|
61
+ cols = (data[:columns] || []).map { |c| "`#{c[:name]}` (#{c[:type]})" }.join(", ")
62
+ lines << "### #{name}"
63
+ lines << cols
64
+ end
65
+ lines.join("\n\n")
66
+ end
67
+
68
+ def models_section
69
+ models = context[:models]
70
+ return if models.is_a?(Hash) && models[:error]
71
+
72
+ lines = ["## Models (#{models.size})"]
73
+ models.each do |name, data|
74
+ next if data[:error]
75
+ assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }.join(", ")
76
+ lines << "### #{name}"
77
+ lines << "- Table: `#{data[:table_name]}`" if data[:table_name]
78
+ lines << "- Associations: #{assocs}" if assocs.present?
79
+ if data[:validations]&.any?
80
+ vals = data[:validations].map { |v| "#{v[:kind]} on #{v[:attributes].join(', ')}" }.join("; ")
81
+ lines << "- Validations: #{vals}"
82
+ end
83
+ lines << "- Enums: #{data[:enums].keys.join(', ')}" if data[:enums]&.any?
84
+ end
85
+ lines.join("\n")
86
+ end
87
+
88
+ def routes_section
89
+ routes = context[:routes]
90
+ return if routes[:error]
91
+
92
+ lines = ["## Routes (#{routes[:total_routes]} total)"]
93
+ routes[:by_controller]&.sort&.each do |ctrl, actions|
94
+ lines << "### #{ctrl}"
95
+ actions.each do |r|
96
+ lines << "- `#{r[:verb]} #{r[:path]}` → #{r[:action]}"
97
+ end
98
+ end
99
+ lines.join("\n")
100
+ end
101
+
102
+ def jobs_section
103
+ jobs = context[:jobs]
104
+ parts = []
105
+
106
+ if jobs[:jobs]&.any?
107
+ parts << "## Background Jobs"
108
+ jobs[:jobs].each { |j| parts << "- `#{j[:name]}` (queue: #{j[:queue]})" }
109
+ end
110
+
111
+ if jobs[:mailers]&.any?
112
+ parts << "## Mailers"
113
+ jobs[:mailers].each { |m| parts << "- `#{m[:name]}`: #{m[:actions].join(', ')}" }
114
+ end
115
+
116
+ if jobs[:channels]&.any?
117
+ parts << "## Action Cable Channels"
118
+ jobs[:channels].each { |c| parts << "- `#{c[:name]}`" }
119
+ end
120
+
121
+ parts.join("\n") if parts.any?
122
+ end
123
+
124
+ def gems_section
125
+ gems = context[:gems]
126
+ return if gems[:error]
127
+
128
+ notable = gems[:notable_gems] || []
129
+ return if notable.empty?
130
+
131
+ lines = ["## Notable Gems"]
132
+ notable.group_by { |g| g[:category] }.sort.each do |cat, group|
133
+ lines << "### #{cat.capitalize}"
134
+ group.each { |g| lines << "- **#{g[:name]}** (#{g[:version]}): #{g[:note]}" }
135
+ end
136
+ lines.join("\n")
137
+ end
138
+
139
+ def conventions_section
140
+ conv = context[:conventions]
141
+ return unless conv[:directory_structure]&.any?
142
+
143
+ lines = ["## Project Structure"]
144
+ conv[:directory_structure].sort.each do |dir, count|
145
+ lines << "- `#{dir}/` — #{count} files"
146
+ end
147
+ lines.join("\n")
148
+ end
149
+
150
+ def footer
151
+ <<~MD
152
+ ---
153
+ _This context file is auto-generated. Run `rails ai:context` to regenerate._
154
+ MD
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsAiContext
6
+ # Configures and starts an MCP server using the official Ruby SDK.
7
+ # Registers all introspection tools and handles transport selection.
8
+ class Server
9
+ attr_reader :app, :transport_type
10
+
11
+ TOOLS = [
12
+ Tools::GetSchema,
13
+ Tools::GetRoutes,
14
+ Tools::GetModelDetails,
15
+ Tools::GetGems,
16
+ Tools::SearchCode,
17
+ Tools::GetConventions
18
+ ].freeze
19
+
20
+ def initialize(app, transport: :stdio)
21
+ @app = app
22
+ @transport_type = transport
23
+ end
24
+
25
+ # Build and return the configured MCP::Server instance
26
+ def build
27
+ config = RailsAiContext.configuration
28
+
29
+ MCP::Server.new(
30
+ name: config.server_name,
31
+ version: config.server_version,
32
+ tools: TOOLS
33
+ )
34
+ end
35
+
36
+ # Start the MCP server with the configured transport
37
+ def start
38
+ server = build
39
+
40
+ case transport_type
41
+ when :stdio
42
+ start_stdio(server)
43
+ when :http, :streamable_http
44
+ start_http(server)
45
+ else
46
+ raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def start_stdio(server)
53
+ transport = MCP::Server::Transports::StdioTransport.new(server)
54
+ # Log to stderr so we don't pollute the JSON-RPC channel on stdout
55
+ $stderr.puts "[rails-ai-context] MCP server started (stdio transport)"
56
+ $stderr.puts "[rails-ai-context] Tools: #{TOOLS.map { |t| t.tool_name }.join(', ')}"
57
+ transport.open
58
+ end
59
+
60
+ def start_http(server)
61
+ config = RailsAiContext.configuration
62
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
63
+
64
+ # Build a minimal Rack app that delegates to the MCP transport
65
+ rack_app = build_rack_app(transport, config.http_path)
66
+
67
+ $stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
68
+ $stderr.puts "[rails-ai-context] Tools: #{TOOLS.map { |t| t.tool_name }.join(', ')}"
69
+
70
+ require "rackup"
71
+ Rackup::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
72
+ rescue LoadError
73
+ # Fallback for older rack without rackup gem
74
+ require "rack/handler"
75
+ Rack::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
76
+ end
77
+
78
+ def build_rack_app(transport, path)
79
+ lambda do |env|
80
+ # Only handle requests at the configured MCP path
81
+ unless env["PATH_INFO"] == path || env["PATH_INFO"] == "#{path}/"
82
+ return [404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']]
83
+ end
84
+
85
+ request = Rack::Request.new(env)
86
+ transport.handle_request(request)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :ai do
4
+ desc "Generate AI context files (CLAUDE.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md)"
5
+ task context: :environment do
6
+ require "rails_ai_context"
7
+
8
+ puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
9
+ context = RailsAiContext.introspect
10
+
11
+ puts "📝 Writing context files..."
12
+ files = RailsAiContext.generate_context(format: :all)
13
+
14
+ files.each { |f| puts " ✅ #{f}" }
15
+ puts ""
16
+ puts "Done! Your AI assistants now understand your Rails app."
17
+ puts "Commit these files so your whole team benefits."
18
+ end
19
+
20
+ desc "Generate AI context in a specific format (claude, cursor, windsurf, copilot, json)"
21
+ task :context_for, [:format] => :environment do |_t, args|
22
+ require "rails_ai_context"
23
+
24
+ format = (args[:format] || "claude").to_sym
25
+ puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
26
+ context = RailsAiContext.introspect
27
+
28
+ puts "📝 Writing #{format} context file..."
29
+ files = RailsAiContext.generate_context(format: format)
30
+
31
+ files.each { |f| puts " ✅ #{f}" }
32
+ end
33
+
34
+ desc "Start the MCP server (stdio transport, for Claude Code / Cursor)"
35
+ task serve: :environment do
36
+ require "rails_ai_context"
37
+
38
+ RailsAiContext.start_mcp_server(transport: :stdio)
39
+ end
40
+
41
+ desc "Start the MCP server with HTTP transport"
42
+ task serve_http: :environment do
43
+ require "rails_ai_context"
44
+
45
+ RailsAiContext.start_mcp_server(transport: :http)
46
+ end
47
+
48
+ desc "Print introspection summary to stdout (useful for debugging)"
49
+ task inspect: :environment do
50
+ require "rails_ai_context"
51
+ require "json"
52
+
53
+ context = RailsAiContext.introspect
54
+
55
+ puts "=" * 60
56
+ puts " #{context[:app_name]} — AI Context Summary"
57
+ puts "=" * 60
58
+ puts ""
59
+ puts "Rails #{context[:rails_version]} | Ruby #{context[:ruby_version]}"
60
+ puts ""
61
+
62
+ if context[:schema] && !context[:schema][:error]
63
+ puts "📦 Database: #{context[:schema][:total_tables]} tables (#{context[:schema][:adapter]})"
64
+ end
65
+
66
+ if context[:models] && !context[:models].is_a?(Hash)
67
+ puts "🏗️ Models: #{context[:models].size}"
68
+ elsif context[:models].is_a?(Hash) && !context[:models][:error]
69
+ puts "🏗️ Models: #{context[:models].size}"
70
+ end
71
+
72
+ if context[:routes] && !context[:routes][:error]
73
+ puts "🛤️ Routes: #{context[:routes][:total_routes]}"
74
+ end
75
+
76
+ if context[:jobs]
77
+ puts "⚡ Jobs: #{context[:jobs][:jobs]&.size || 0}"
78
+ puts "📧 Mailers: #{context[:jobs][:mailers]&.size || 0}"
79
+ end
80
+
81
+ if context[:conventions]
82
+ arch = context[:conventions][:architecture] || []
83
+ puts "🏛️ Architecture: #{arch.join(', ')}" if arch.any?
84
+ end
85
+
86
+ puts ""
87
+ puts "Run `rails ai:context` to generate context files."
88
+ end
89
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module RailsAiContext
6
+ module Tools
7
+ # Base class for all MCP tools exposed by rails-ai-context.
8
+ # Inherits from the official MCP::Tool to get schema validation,
9
+ # annotations, and protocol compliance for free.
10
+ class BaseTool < MCP::Tool
11
+ class << self
12
+ # Convenience: access the Rails app and cached introspection
13
+ def rails_app
14
+ Rails.application
15
+ end
16
+
17
+ def config
18
+ RailsAiContext.configuration
19
+ end
20
+
21
+ # Cache introspection results for the lifetime of the server process
22
+ def cached_context
23
+ @cached_context ||= RailsAiContext.introspect
24
+ end
25
+
26
+ def reset_cache!
27
+ @cached_context = nil
28
+ end
29
+
30
+ # Helper: wrap text in an MCP::Tool::Response
31
+ def text_response(text)
32
+ MCP::Tool::Response.new([{ type: "text", text: text }])
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ class GetConventions < BaseTool
6
+ tool_name "rails_get_conventions"
7
+ description "Detect architectural patterns and conventions used in this Rails app. Returns info about architecture style (API-only, Hotwire, GraphQL), design patterns (service objects, STI, polymorphism), directory structure, and config files present."
8
+
9
+ input_schema(properties: {})
10
+
11
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
12
+
13
+ def self.call(server_context: nil)
14
+ conventions = cached_context[:conventions]
15
+ return text_response("Convention detection failed: #{conventions[:error]}") if conventions.is_a?(Hash) && conventions[:error]
16
+
17
+ lines = ["# App Conventions & Architecture", ""]
18
+
19
+ # Architecture
20
+ if conventions[:architecture]&.any?
21
+ lines << "## Architecture"
22
+ conventions[:architecture].each { |a| lines << "- #{humanize_arch(a)}" }
23
+ end
24
+
25
+ # Patterns
26
+ if conventions[:patterns]&.any?
27
+ lines << "" << "## Detected patterns"
28
+ conventions[:patterns].each { |p| lines << "- #{humanize_pattern(p)}" }
29
+ end
30
+
31
+ # Directory structure
32
+ if conventions[:directory_structure]&.any?
33
+ lines << "" << "## Directory structure"
34
+ conventions[:directory_structure].sort_by { |k, _| k }.each do |dir, count|
35
+ lines << "- `#{dir}/` → #{count} files"
36
+ end
37
+ end
38
+
39
+ # Config files
40
+ if conventions[:config_files]&.any?
41
+ lines << "" << "## Config files present"
42
+ conventions[:config_files].each { |f| lines << "- `#{f}`" }
43
+ end
44
+
45
+ text_response(lines.join("\n"))
46
+ end
47
+
48
+ ARCH_LABELS = {
49
+ "api_only" => "API-only mode (no views/assets)",
50
+ "hotwire" => "Hotwire (Turbo + Stimulus)",
51
+ "graphql" => "GraphQL API (app/graphql/)",
52
+ "grape_api" => "Grape API framework (app/api/)",
53
+ "service_objects" => "Service objects pattern (app/services/)",
54
+ "form_objects" => "Form objects (app/forms/)",
55
+ "query_objects" => "Query objects (app/queries/)",
56
+ "presenters" => "Presenters/Decorators",
57
+ "view_components" => "ViewComponent (app/components/)",
58
+ "stimulus" => "Stimulus controllers (app/javascript/controllers/)",
59
+ "importmaps" => "Import maps (no JS bundler)",
60
+ "docker" => "Dockerized",
61
+ "kamal" => "Kamal deployment",
62
+ "ci_github_actions" => "GitHub Actions CI"
63
+ }.freeze
64
+
65
+ PATTERN_LABELS = {
66
+ "sti" => "Single Table Inheritance (STI)",
67
+ "polymorphic" => "Polymorphic associations",
68
+ "soft_delete" => "Soft deletes (paranoia/discard)",
69
+ "versioning" => "Model versioning/auditing",
70
+ "state_machine" => "State machines (AASM/workflow)",
71
+ "multi_tenancy" => "Multi-tenancy",
72
+ "searchable" => "Full-text search (Searchkick/pg_search/Ransack)",
73
+ "taggable" => "Tagging",
74
+ "sluggable" => "Friendly URLs/slugs",
75
+ "nested_set" => "Tree/nested set structures"
76
+ }.freeze
77
+
78
+ private_class_method def self.humanize_arch(key)
79
+ ARCH_LABELS[key] || key.humanize
80
+ end
81
+
82
+ private_class_method def self.humanize_pattern(key)
83
+ PATTERN_LABELS[key] || key.humanize
84
+ end
85
+ end
86
+ end
87
+ end