ask-rails-harness 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +122 -0
- data/app/controllers/ask/rails/harness/chat_controller.rb +310 -0
- data/app/models/ask/rails/harness/session.rb +25 -0
- data/app/views/ask/rails/harness/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/harness/application.html.erb +521 -0
- data/config/routes.rb +17 -0
- data/lib/ask/rails/harness/audit_log.rb +183 -0
- data/lib/ask/rails/harness/auth.rb +24 -0
- data/lib/ask/rails/harness/configuration.rb +73 -0
- data/lib/ask/rails/harness/engine.rb +13 -0
- data/lib/ask/rails/harness/environment_permissions.rb +41 -0
- data/lib/ask/rails/harness/persistence.rb +54 -0
- data/lib/ask/rails/harness/railtie.rb +34 -0
- data/lib/ask/rails/harness/service_discovery.rb +50 -0
- data/lib/ask/rails/harness/tool.rb +42 -0
- data/lib/ask/rails/harness/tools/query_database.rb +74 -0
- data/lib/ask/rails/harness/tools/read_file.rb +23 -0
- data/lib/ask/rails/harness/tools/read_log.rb +120 -0
- data/lib/ask/rails/harness/tools/read_model.rb +75 -0
- data/lib/ask/rails/harness/tools/read_routes.rb +20 -0
- data/lib/ask/rails/harness/tools/route_inspector.rb +71 -0
- data/lib/ask/rails/harness/tools/run_command.rb +60 -0
- data/lib/ask/rails/harness/tools/schema_graph.rb +176 -0
- data/lib/ask/rails/harness/tools/search_codebase.rb +23 -0
- data/lib/ask/rails/harness/version.rb +9 -0
- data/lib/ask/rails/harness.rb +189 -0
- data/lib/ask/skills/rails.db_debug/SKILL.md +118 -0
- data/lib/ask/skills/rails.deploy_pipeline/SKILL.md +125 -0
- data/lib/ask/skills/rails.route_trouble/SKILL.md +127 -0
- data/lib/ask-rails-harness.rb +3 -0
- data/lib/generators/ask/rails/harness/install/install_generator.rb +36 -0
- data/lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/harness/install/templates/initializer.rb +5 -0
- data/lib/generators/ask/rails/harness/install/templates/migration.rb +12 -0
- metadata +208 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadLog < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Read application log files with filtering. Supports Rails default " \
|
|
9
|
+
"logger and log rotation. Reads from the end of the file (most recent first)."
|
|
10
|
+
|
|
11
|
+
param :lines, type: :integer, desc: "Number of recent lines (default 50, max 500)", required: false
|
|
12
|
+
param :level, type: :string, desc: "Filter by level: ERROR, WARN, INFO, DEBUG", required: false
|
|
13
|
+
param :search, type: :string, desc: "Search term (plain text, case-insensitive)", required: false
|
|
14
|
+
param :file, type: :string, desc: "Log file name (default: log/<env>.log)", required: false
|
|
15
|
+
|
|
16
|
+
MAX_LINES = 500
|
|
17
|
+
LEVEL_PATTERNS = {
|
|
18
|
+
"ERROR" => /\bERROR\b/i,
|
|
19
|
+
"WARN" => /\bWARN\b/i,
|
|
20
|
+
"INFO" => /\bINFO\b/i,
|
|
21
|
+
"DEBUG" => /\bDEBUG\b/i
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def execute(lines: 50, level: nil, search: nil, file: nil)
|
|
25
|
+
max_lines = [lines.to_i, MAX_LINES].min
|
|
26
|
+
log_path = resolve_log_path(file)
|
|
27
|
+
|
|
28
|
+
unless log_path.exist?
|
|
29
|
+
return Ask::Result.failure(
|
|
30
|
+
"Log file not found: #{log_path}. The application may not have written any logs yet."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raw_lines = read_all_log_files(log_path)
|
|
35
|
+
return { lines: [], total_lines: 0, path: log_path.to_s } if raw_lines.empty?
|
|
36
|
+
|
|
37
|
+
filtered = apply_filters(raw_lines, level: level, search: search)
|
|
38
|
+
recent = filtered.last(max_lines).map(&:chomp)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
lines: recent,
|
|
42
|
+
total_lines: raw_lines.size,
|
|
43
|
+
matched_lines: filtered.size,
|
|
44
|
+
path: log_path.to_s,
|
|
45
|
+
filters_applied: { level: level, search: search }.compact
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def resolve_log_path(custom_path)
|
|
52
|
+
return rails_root.join(custom_path) if custom_path
|
|
53
|
+
rails_root.join("log", "#{Rails.env}.log")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Read from rotated archives too: log/production.log, .1, .2.gz, etc.
|
|
57
|
+
def read_all_log_files(log_path)
|
|
58
|
+
all_content = +""
|
|
59
|
+
rotated_files(log_path).each do |path|
|
|
60
|
+
content = read_file_content(path)
|
|
61
|
+
all_content.prepend(content) if content
|
|
62
|
+
end
|
|
63
|
+
all_content.lines
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def rotated_files(log_path)
|
|
67
|
+
dir = log_path.dirname
|
|
68
|
+
base = log_path.basename.to_s
|
|
69
|
+
# Primary file, then rotated files in reverse order (oldest first, then primary last)
|
|
70
|
+
pattern = File.join(dir, "#{base}.*")
|
|
71
|
+
rotations = Dir[pattern].sort_by { |f| extract_rotation_number(f) }
|
|
72
|
+
# Primary file is read last (most recent)
|
|
73
|
+
rotations + [log_path.to_s]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_rotation_number(path)
|
|
77
|
+
File.basename(path).sub(/.*\.(\d+)(\.gz)?$/, '\1').to_i
|
|
78
|
+
rescue
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_file_content(path)
|
|
83
|
+
if path.to_s.end_with?(".gz")
|
|
84
|
+
Zlib::GzipReader.open(path.to_s) { |gz| gz.read }
|
|
85
|
+
else
|
|
86
|
+
File.read(path.to_s)
|
|
87
|
+
end
|
|
88
|
+
rescue => e
|
|
89
|
+
warn "[ReadLog] Could not read #{path}: #{e.message}"
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_tail(path, max_bytes)
|
|
94
|
+
return "" unless path.exist?
|
|
95
|
+
|
|
96
|
+
size = path.size
|
|
97
|
+
return path.read if size <= max_bytes
|
|
98
|
+
|
|
99
|
+
File.open(path, "rb") do |f|
|
|
100
|
+
f.seek(-max_bytes, IO::SEEK_END)
|
|
101
|
+
partial = f.read(max_bytes)
|
|
102
|
+
if (idx = partial.index("\n"))
|
|
103
|
+
partial[idx + 1..]
|
|
104
|
+
else
|
|
105
|
+
partial
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def apply_filters(lines, level: nil, search: nil)
|
|
111
|
+
filtered = lines
|
|
112
|
+
filtered = filtered.select { |l| LEVEL_PATTERNS.fetch(level) { // }.match?(l) } if level
|
|
113
|
+
filtered = filtered.select { |l| l.downcase.include?(search.downcase) } if search
|
|
114
|
+
filtered
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadModel < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Inspect an ActiveRecord model — columns, associations, validations, " \
|
|
9
|
+
"scopes, and indexes. Returns structured data the agent can act on."
|
|
10
|
+
|
|
11
|
+
param :name, type: :string, desc: "Model class name (e.g. 'User', 'Blog::Post')", required: true
|
|
12
|
+
param :detail, type: :string, desc: "Which details: 'all' (default), 'columns', 'associations', 'validations', 'scopes'", required: false
|
|
13
|
+
|
|
14
|
+
def execute(name:, detail: "all")
|
|
15
|
+
klass = safe_constantize(name)
|
|
16
|
+
return Ask::Result.failure("Model '#{name}' not found or is not an ActiveRecord model.") unless klass
|
|
17
|
+
|
|
18
|
+
result = { name: klass.name, table_name: klass.table_name }
|
|
19
|
+
|
|
20
|
+
result[:primary_key] = klass.primary_key if klass.respond_to?(:primary_key)
|
|
21
|
+
|
|
22
|
+
if %w[all columns].include?(detail)
|
|
23
|
+
result[:columns] = klass.columns.map { |c|
|
|
24
|
+
entry = { name: c.name, type: c.type, null: c.null, default: c.default }
|
|
25
|
+
entry[:primary_key] = true if c.name == klass.primary_key
|
|
26
|
+
entry
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if %w[all associations].include?(detail)
|
|
31
|
+
result[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
32
|
+
refs.map { |a|
|
|
33
|
+
entry = { name: a.name, class_name: a.class_name }
|
|
34
|
+
entry[:through] = a.options[:through] if a.options[:through]
|
|
35
|
+
entry[:source] = a.options[:source] if a.options[:source]
|
|
36
|
+
entry[:foreign_key] = a.foreign_key if a.respond_to?(:foreign_key)
|
|
37
|
+
entry
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if %w[all scopes].include?(detail) && klass.respond_to?(:all)
|
|
43
|
+
result[:scopes] = klass.methods(false)
|
|
44
|
+
.reject { |m| m.to_s.end_with?("=", "!", "?") || %i[new allocate inspect to_s].include?(m) }
|
|
45
|
+
.map(&:to_s).sort
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if %w[all validations].include?(detail)
|
|
49
|
+
result[:validators] = klass.validators.map { |v|
|
|
50
|
+
{
|
|
51
|
+
attribute: v.attributes.first&.to_s,
|
|
52
|
+
kind: v.kind,
|
|
53
|
+
options: v.options.reject { |k, _| k == :if }
|
|
54
|
+
}
|
|
55
|
+
}.reject { |v| v[:attribute].nil? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def safe_constantize(name)
|
|
64
|
+
klass = name.safe_constantize
|
|
65
|
+
return nil unless klass
|
|
66
|
+
return nil unless klass < ActiveRecord::Base
|
|
67
|
+
klass
|
|
68
|
+
rescue
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadRoutes < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Read the Rails routes from config/routes.rb"
|
|
9
|
+
def execute
|
|
10
|
+
routes_file = rails_root.join("config", "routes.rb")
|
|
11
|
+
return Ask::Result.error(message: "No routes file found") unless routes_file.exist?
|
|
12
|
+
|
|
13
|
+
content = routes_file.read
|
|
14
|
+
{ content: content, size: content.length }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class RouteInspector < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Return the parsed Rails route table — every route with its HTTP verb, path, " \
|
|
9
|
+
"controller, action, and name. Returns structured data the agent can filter " \
|
|
10
|
+
"and reason about, unlike reading the raw routes.rb file."
|
|
11
|
+
|
|
12
|
+
param :controller, type: :string, desc: "Filter by controller name (e.g. 'users', 'api/v1/posts')", required: false
|
|
13
|
+
param :pattern, type: :string, desc: "Filter by path pattern (e.g. 'users', 'admin')", required: false
|
|
14
|
+
param :verbose, type: :boolean, desc: "Include internal route details (default false)", required: false
|
|
15
|
+
|
|
16
|
+
def execute(controller: nil, pattern: nil, verbose: false)
|
|
17
|
+
return { routes: [], count: 0 } unless defined?(::Rails) && ::Rails.application&.routes
|
|
18
|
+
|
|
19
|
+
all_routes = ::Rails.application.routes.routes.map do |route|
|
|
20
|
+
entry = {
|
|
21
|
+
verb: verb_for(route),
|
|
22
|
+
path: route.path.spec.to_s.delete_suffix("(.:format)"),
|
|
23
|
+
controller: route.defaults[:controller]&.to_s,
|
|
24
|
+
action: route.defaults[:action]&.to_s,
|
|
25
|
+
name: route.name&.to_s
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if verbose
|
|
29
|
+
entry[:requirements] = route.required_parts.presence
|
|
30
|
+
entry[:defaults] = route.defaults.except(:controller, :action).presence
|
|
31
|
+
entry[:constraints] = route.constraints.except(:request_method).presence if route.constraints.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
entry
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remove internal/engine routes unless verbose
|
|
38
|
+
unless verbose
|
|
39
|
+
all_routes.reject! { |r| r[:controller].nil? || r[:controller].start_with?("rails/") }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Apply filters
|
|
43
|
+
all_routes.select! { |r| r[:controller] == controller } if controller
|
|
44
|
+
all_routes.select! { |r| r[:path].include?(pattern) } if pattern
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
routes: all_routes,
|
|
48
|
+
count: all_routes.size
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def verb_for(route)
|
|
55
|
+
if route.verb.is_a?(String)
|
|
56
|
+
route.verb
|
|
57
|
+
elsif route.verb.respond_to?(:call)
|
|
58
|
+
# Rails stores verb as a regexp — extract the source
|
|
59
|
+
source = route.verb.source
|
|
60
|
+
source.gsub(/[$^\\]/, "").split("|")
|
|
61
|
+
else
|
|
62
|
+
"ANY"
|
|
63
|
+
end
|
|
64
|
+
rescue
|
|
65
|
+
"ANY"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class RunCommand < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Run a shell command in the Rails app root directory."
|
|
9
|
+
param :command, type: :string, desc: "Shell command to run", required: true
|
|
10
|
+
|
|
11
|
+
def execute(command:)
|
|
12
|
+
check_result = check_command_allowed(command)
|
|
13
|
+
return check_result if check_result
|
|
14
|
+
|
|
15
|
+
output = `cd #{rails_root} && #{command} 2>&1`
|
|
16
|
+
Ask::Result.ok(
|
|
17
|
+
data: { output: output, exit_status: $?.exitstatus },
|
|
18
|
+
metadata: { exit_status: $?.exitstatus }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def check_command_allowed(command)
|
|
25
|
+
config = Ask::Rails::Harness.configuration
|
|
26
|
+
|
|
27
|
+
# Use per-environment rules if configured, fall back to global
|
|
28
|
+
denied = config.effective_denied_commands
|
|
29
|
+
allowed = config.effective_allowed_commands
|
|
30
|
+
|
|
31
|
+
# 1. Check denied commands first (takes precedence)
|
|
32
|
+
if denied
|
|
33
|
+
denied.each do |pattern|
|
|
34
|
+
if command.match?(pattern)
|
|
35
|
+
return Ask::Result.error(
|
|
36
|
+
message: "Command blocked by deny rule: #{pattern.inspect}"
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 2. Check allowed commands (if configured)
|
|
43
|
+
if allowed
|
|
44
|
+
matches = allowed.any? { |pattern| command.match?(pattern) }
|
|
45
|
+
unless matches
|
|
46
|
+
allowed_desc = allowed.map(&:inspect).join(", ")
|
|
47
|
+
return Ask::Result.error(
|
|
48
|
+
message: "Command blocked: does not match any allowed pattern (#{allowed_desc})"
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 3. No restrictions configured — allow
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class SchemaGraph < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Return the full application schema graph — all models, tables, columns with types, " \
|
|
9
|
+
"associations (belongs_to, has_many, has_one, HABTM, through), validations, indexes, " \
|
|
10
|
+
"and polymorphic relationships. One call gives the agent a complete mental model " \
|
|
11
|
+
"of the application's data layer."
|
|
12
|
+
|
|
13
|
+
param :detail, type: :string, desc: "Detail level: 'all' (default), 'models', 'associations', 'tables'", required: false
|
|
14
|
+
|
|
15
|
+
TABLE_EXCLUSIONS = %w[schema_migrations ar_internal_metadata].freeze
|
|
16
|
+
|
|
17
|
+
def execute(detail: "all")
|
|
18
|
+
models = discover_ar_models
|
|
19
|
+
|
|
20
|
+
result = {
|
|
21
|
+
summary: {
|
|
22
|
+
model_count: models.size,
|
|
23
|
+
table_count: models.map { |m| m.table_name }.uniq.size,
|
|
24
|
+
association_count: models.sum { |m| m.reflect_on_all_associations.size }
|
|
25
|
+
},
|
|
26
|
+
models: %w[all models].include?(detail) ? build_model_details(models, detail) : nil,
|
|
27
|
+
associations: %w[all associations].include?(detail) ? build_association_graph(models) : nil,
|
|
28
|
+
tables: %w[all tables].include?(detail) ? build_table_details(detail) : nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def discover_ar_models
|
|
37
|
+
if defined?(::Rails::Application) && ::Rails.application
|
|
38
|
+
::Rails.application.eager_load! rescue nil
|
|
39
|
+
end
|
|
40
|
+
ActiveRecord::Base.descendants.reject do |klass|
|
|
41
|
+
klass.abstract_class? ||
|
|
42
|
+
klass.name.nil? ||
|
|
43
|
+
TABLE_EXCLUSIONS.include?(klass.table_name) ||
|
|
44
|
+
klass.name.start_with?("ActiveRecord::", "ActiveStorage::", "ActionText::")
|
|
45
|
+
end.sort_by(&:name)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_model_details(models, detail)
|
|
51
|
+
models.filter_map do |klass|
|
|
52
|
+
name = klass.name rescue nil
|
|
53
|
+
next nil unless name
|
|
54
|
+
|
|
55
|
+
entry = {
|
|
56
|
+
name: name,
|
|
57
|
+
table_name: klass.table_name,
|
|
58
|
+
primary_key: safe_primary_key(klass)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if klass.respond_to?(:columns)
|
|
62
|
+
entry[:columns] = safe_columns(klass)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if klass.respond_to?(:reflect_on_all_associations)
|
|
66
|
+
entry[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
67
|
+
refs.map { |a|
|
|
68
|
+
assoc = { name: a.name, class_name: a.class_name }
|
|
69
|
+
assoc[:through] = a.options[:through] if a.options[:through]
|
|
70
|
+
assoc[:source] = a.options[:source] if a.options[:source]
|
|
71
|
+
assoc[:foreign_key] = a.foreign_key
|
|
72
|
+
assoc[:polymorphic] = true if a.polymorphic?
|
|
73
|
+
assoc[:as] = a.options[:as] if a.options[:as]
|
|
74
|
+
assoc[:dependent] = a.options[:dependent] if a.options[:dependent]
|
|
75
|
+
assoc
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if klass.respond_to?(:validators)
|
|
81
|
+
entry[:validators] = klass.validators.map { |v|
|
|
82
|
+
{
|
|
83
|
+
attribute: v.attributes.first&.to_s,
|
|
84
|
+
kind: v.kind,
|
|
85
|
+
options: v.options.reject { |k, _| k == :if }
|
|
86
|
+
}
|
|
87
|
+
}.reject { |v| v[:attribute].nil? }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
entry
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_association_graph(models)
|
|
95
|
+
edges = []
|
|
96
|
+
|
|
97
|
+
models.each do |klass|
|
|
98
|
+
klass.reflect_on_all_associations.each do |a|
|
|
99
|
+
next if a.macro == :has_many && a.options[:through] # Skip through associations (derived)
|
|
100
|
+
|
|
101
|
+
target_model = find_model_for_association(models, a)
|
|
102
|
+
next unless target_model
|
|
103
|
+
|
|
104
|
+
edges << {
|
|
105
|
+
from: klass.name,
|
|
106
|
+
to: target_model.name,
|
|
107
|
+
type: a.macro,
|
|
108
|
+
via: a.name,
|
|
109
|
+
foreign_key: a.foreign_key,
|
|
110
|
+
polymorphic: a.polymorphic? || false
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
edges
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_model_for_association(models, association)
|
|
119
|
+
class_name = association.class_name
|
|
120
|
+
# Try exact match first, then match by class name suffix (handles namespace issues)
|
|
121
|
+
models.find { |m| m.name == class_name } ||
|
|
122
|
+
models.find { |m| m.name.end_with?("::#{class_name}") } ||
|
|
123
|
+
models.find { |m| class_name.end_with?("::#{m.name}") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_table_details(detail)
|
|
127
|
+
models = discover_ar_models
|
|
128
|
+
tables = {}
|
|
129
|
+
|
|
130
|
+
models.each do |klass|
|
|
131
|
+
table = klass.table_name
|
|
132
|
+
next if TABLE_EXCLUSIONS.include?(table)
|
|
133
|
+
|
|
134
|
+
tables[table] = {
|
|
135
|
+
model: klass.name,
|
|
136
|
+
columns: safe_columns(klass),
|
|
137
|
+
indexes: fetch_indexes_for(table)
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
tables
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def safe_primary_key(klass)
|
|
145
|
+
klass.primary_key
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def safe_columns(klass)
|
|
151
|
+
klass.columns.map { |c|
|
|
152
|
+
col = { name: c.name, type: c.type, null: c.null }
|
|
153
|
+
col[:default] = c.default unless c.default.nil?
|
|
154
|
+
col[:primary_key] = true if c.name == klass.primary_key
|
|
155
|
+
col
|
|
156
|
+
}
|
|
157
|
+
rescue StandardError
|
|
158
|
+
[]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fetch_indexes_for(table_name)
|
|
162
|
+
ActiveRecord::Base.connection.indexes(table_name).map do |idx|
|
|
163
|
+
{
|
|
164
|
+
name: idx.name,
|
|
165
|
+
columns: idx.columns,
|
|
166
|
+
unique: idx.unique
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
rescue StandardError
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class SearchCodebase < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Search the Rails codebase with grep."
|
|
9
|
+
param :pattern, type: :string, desc: "Search pattern", required: true
|
|
10
|
+
param :path, type: :string, desc: "Subdirectory to search (optional)", required: false
|
|
11
|
+
|
|
12
|
+
def execute(pattern:, path: nil)
|
|
13
|
+
search_path = (path ? rails_root.join(path) : rails_root).to_s
|
|
14
|
+
escaped_pattern = pattern.gsub("'", "'\\\\''")
|
|
15
|
+
escaped_path = search_path.gsub("'", "'\\\\''")
|
|
16
|
+
results = `cd #{rails_root} && grep -rn '#{escaped_pattern}' #{escaped_path} 2>&1 | head -50`
|
|
17
|
+
{ results: results.lines.map(&:chomp), count: results.lines.count }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|