ask-ruby-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 +19 -0
- data/LICENSE +21 -0
- data/README.md +67 -0
- data/lib/ask/ruby/harness/audit_log.rb +187 -0
- data/lib/ask/ruby/harness/configuration.rb +77 -0
- data/lib/ask/ruby/harness/environment_permissions.rb +41 -0
- data/lib/ask/ruby/harness/minitest_json_reporter.rb +84 -0
- data/lib/ask/ruby/harness/tool.rb +46 -0
- data/lib/ask/ruby/harness/tools/query_database.rb +87 -0
- data/lib/ask/ruby/harness/tools/read_log.rb +103 -0
- data/lib/ask/ruby/harness/tools/read_model.rb +77 -0
- data/lib/ask/ruby/harness/tools/run_command.rb +60 -0
- data/lib/ask/ruby/harness/tools/run_tests.rb +262 -0
- data/lib/ask/ruby/harness/tools/schema_graph.rb +179 -0
- data/lib/ask/ruby/harness/version.rb +9 -0
- data/lib/ask/ruby/harness.rb +216 -0
- data/lib/minitest/ask_ruby_harness_plugin.rb +39 -0
- metadata +177 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadLog < Ask::Ruby::Harness::Tool
|
|
8
|
+
description "Read application log files with filtering. Supports standard " \
|
|
9
|
+
"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 app_root.join(custom_path) if custom_path
|
|
53
|
+
app_root.join("log", "#{Ask::Ruby::Harness.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 apply_filters(lines, level: nil, search: nil)
|
|
94
|
+
filtered = lines
|
|
95
|
+
filtered = filtered.select { |l| LEVEL_PATTERNS.fetch(level) { // }.match?(l) } if level
|
|
96
|
+
filtered = filtered.select { |l| l.downcase.include?(search.downcase) } if search
|
|
97
|
+
filtered
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Ruby
|
|
7
|
+
module Harness
|
|
8
|
+
module Tools
|
|
9
|
+
class ReadModel < Ask::Ruby::Harness::Tool
|
|
10
|
+
description "Inspect an ActiveRecord model — columns, associations, validations, " \
|
|
11
|
+
"scopes, and indexes. Returns structured data the agent can act on."
|
|
12
|
+
|
|
13
|
+
param :name, type: :string, desc: "Model class name (e.g. 'User', 'Blog::Post')", required: true
|
|
14
|
+
param :detail, type: :string, desc: "Which details: 'all' (default), 'columns', 'associations', 'validations', 'scopes'", required: false
|
|
15
|
+
|
|
16
|
+
def execute(name:, detail: "all")
|
|
17
|
+
klass = safe_constantize(name)
|
|
18
|
+
return Ask::Result.failure("Model '#{name}' not found or is not an ActiveRecord model.") unless klass
|
|
19
|
+
|
|
20
|
+
result = { name: klass.name, table_name: klass.table_name }
|
|
21
|
+
|
|
22
|
+
result[:primary_key] = klass.primary_key if klass.respond_to?(:primary_key)
|
|
23
|
+
|
|
24
|
+
if %w[all columns].include?(detail)
|
|
25
|
+
result[:columns] = klass.columns.map { |c|
|
|
26
|
+
entry = { name: c.name, type: c.type, null: c.null, default: c.default }
|
|
27
|
+
entry[:primary_key] = true if c.name == klass.primary_key
|
|
28
|
+
entry
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if %w[all associations].include?(detail)
|
|
33
|
+
result[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
34
|
+
refs.map { |a|
|
|
35
|
+
entry = { name: a.name, class_name: a.class_name }
|
|
36
|
+
entry[:through] = a.options[:through] if a.options[:through]
|
|
37
|
+
entry[:source] = a.options[:source] if a.options[:source]
|
|
38
|
+
entry[:foreign_key] = a.foreign_key if a.respond_to?(:foreign_key)
|
|
39
|
+
entry
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if %w[all scopes].include?(detail) && klass.respond_to?(:all)
|
|
45
|
+
result[:scopes] = klass.methods(false)
|
|
46
|
+
.reject { |m| m.to_s.end_with?("=", "!", "?") || %i[new allocate inspect to_s].include?(m) }
|
|
47
|
+
.map(&:to_s).sort
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if %w[all validations].include?(detail)
|
|
51
|
+
result[:validators] = klass.validators.map { |v|
|
|
52
|
+
{
|
|
53
|
+
attribute: v.attributes.first&.to_s,
|
|
54
|
+
kind: v.kind,
|
|
55
|
+
options: v.options.reject { |k, _| k == :if }
|
|
56
|
+
}
|
|
57
|
+
}.reject { |v| v[:attribute].nil? }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def safe_constantize(name)
|
|
66
|
+
klass = name.safe_constantize
|
|
67
|
+
return nil unless klass
|
|
68
|
+
return nil unless klass < ActiveRecord::Base
|
|
69
|
+
klass
|
|
70
|
+
rescue
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class RunCommand < Ask::Ruby::Harness::Tool
|
|
8
|
+
description "Run a shell command in the project 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 #{app_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::Ruby::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,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Ruby
|
|
8
|
+
module Harness
|
|
9
|
+
module Tools
|
|
10
|
+
class RunTests < Ask::Ruby::Harness::Tool
|
|
11
|
+
description "Run the project's test suite and return structured results — summary counts " \
|
|
12
|
+
"plus per-test file/line/message for failures, never raw terminal output. " \
|
|
13
|
+
"Detects the runner (bin/rails test, rspec, or rake test). Minitest gets a " \
|
|
14
|
+
"JSON reporter via the bundled minitest plugin; rspec uses its built-in JSON " \
|
|
15
|
+
"formatter. Rerun only the previous run's failures with failed_only."
|
|
16
|
+
|
|
17
|
+
param :file, type: :string, desc: "Test file path(s) relative to the project root (comma-separated for multiple)", required: false
|
|
18
|
+
param :name, type: :string, desc: "Test name pattern: minitest --name (string or /regex/), rspec -e", required: false
|
|
19
|
+
param :failed_only, type: :boolean, desc: "Rerun only the tests that failed in the previous run", required: false
|
|
20
|
+
param :timeout, type: :integer, desc: "Max seconds to wait before killing the run (default 300)", required: false
|
|
21
|
+
|
|
22
|
+
DEFAULT_TIMEOUT = 300
|
|
23
|
+
ARTIFACT_DIR = %w[tmp test .ask].freeze
|
|
24
|
+
|
|
25
|
+
def execute(file: nil, name: nil, failed_only: false, timeout: DEFAULT_TIMEOUT)
|
|
26
|
+
files = split_files(file)
|
|
27
|
+
runner = detect_runner
|
|
28
|
+
|
|
29
|
+
failed_tests = failed_only ? load_failed_tests : nil
|
|
30
|
+
if failed_only && failed_tests.empty?
|
|
31
|
+
return Ask::Result.failure("No failed tests from the previous run to rerun.")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
artifact_dir = app_root.join(*ARTIFACT_DIR).tap(&:mkpath)
|
|
35
|
+
log_path = artifact_dir.join("last-test.log")
|
|
36
|
+
json_path = artifact_dir.join("last-test.json")
|
|
37
|
+
status_path = artifact_dir.join("last-failures.json")
|
|
38
|
+
|
|
39
|
+
command, env = build_command(runner, files, name, failed_tests, json_path)
|
|
40
|
+
outcome = run(command, env, log_path, timeout)
|
|
41
|
+
|
|
42
|
+
results = parse_results(runner, json_path)
|
|
43
|
+
unless results
|
|
44
|
+
# A killed run can't produce results — report it structurally
|
|
45
|
+
# instead of failing, so the agent still gets the artifact path.
|
|
46
|
+
return Ask::Result.ok(data: timed_out_report(runner, command, env, outcome, status_path, log_path)) if outcome[:timed_out]
|
|
47
|
+
|
|
48
|
+
return Ask::Result.failure(
|
|
49
|
+
"Test run finished without machine-readable results (#{runner}); " \
|
|
50
|
+
"full output at #{rel(log_path)}"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
report = build_report(runner, command, env, outcome, results, status_path, log_path)
|
|
55
|
+
Ask::Result.ok(data: report)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def split_files(file)
|
|
61
|
+
return [] if file.nil? || file.to_s.strip.empty?
|
|
62
|
+
file.split(",").map(&:strip).reject(&:empty?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Rails app → bin/rails test; rspec in the bundle with a spec/ dir →
|
|
66
|
+
# rspec; anything else → minitest via rake test.
|
|
67
|
+
def detect_runner
|
|
68
|
+
return :rails if app_root.join("bin", "rails").exist?
|
|
69
|
+
lockfile = app_root.join("Gemfile.lock")
|
|
70
|
+
rspec = lockfile.exist? && lockfile.read.include?("rspec")
|
|
71
|
+
return :rspec if rspec && app_root.join("spec").directory?
|
|
72
|
+
:minitest
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_command(runner, files, name, failed_tests, json_path)
|
|
76
|
+
case runner
|
|
77
|
+
when :rspec
|
|
78
|
+
args = ["bundle", "exec", "rspec"]
|
|
79
|
+
args.concat(files)
|
|
80
|
+
args.concat(failed_tests.map { |t| "#{t[:file]}:#{t[:line]}" }) if failed_tests
|
|
81
|
+
args.concat(["-e", name]) if name
|
|
82
|
+
args.concat(["--format", "json", "--out", json_path.to_s])
|
|
83
|
+
[args, {}]
|
|
84
|
+
when :rails
|
|
85
|
+
# Rails' `rails test` passes CLI args straight to minitest.
|
|
86
|
+
args = ["bin/rails", "test"]
|
|
87
|
+
args.concat(files)
|
|
88
|
+
args.concat(["-n", name]) if name
|
|
89
|
+
args.concat(["-n", name_pattern(failed_tests)]) if failed_tests
|
|
90
|
+
[args, injection_env(json_path)]
|
|
91
|
+
else
|
|
92
|
+
# Plain Ruby project: rake test. Rake::TestTask reads TESTOPTS
|
|
93
|
+
# (passed to ruby's ARGV, where rake_test_loader keeps only
|
|
94
|
+
# `-`-prefixed args — so options must use the attached
|
|
95
|
+
# --name=... form) and TEST (single file); the JSON reporter
|
|
96
|
+
# arrives via RUBYOPT like everywhere else.
|
|
97
|
+
args = ["bundle", "exec", "rake", "test"]
|
|
98
|
+
env = injection_env(json_path)
|
|
99
|
+
env["TEST"] = files.first if files.size == 1
|
|
100
|
+
testopts = []
|
|
101
|
+
testopts << "--name=#{name}" if name
|
|
102
|
+
testopts << "--name=#{name_pattern(failed_tests)}" if failed_tests
|
|
103
|
+
env["TESTOPTS"] = testopts.join(" ") unless testopts.empty?
|
|
104
|
+
[args, env]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# minitest 6 dropped both plugin auto-discovery and the -r option.
|
|
109
|
+
# Activate the project's bundle first, then require the plugin by
|
|
110
|
+
# absolute path — it pushes its extension, and init_plugins
|
|
111
|
+
# registers the JSON reporter later.
|
|
112
|
+
def injection_env(json_path)
|
|
113
|
+
{
|
|
114
|
+
"ASK_TEST_JSON_PATH" => json_path.to_s,
|
|
115
|
+
"RUBYOPT" => "-rbundler/setup -r#{minitest_plugin_path}"
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def minitest_plugin_path
|
|
120
|
+
spec = Gem.loaded_specs["ask-ruby-harness"]
|
|
121
|
+
spec ||= Gem::Specification.find_by_name("ask-ruby-harness")
|
|
122
|
+
File.join(spec.full_gem_path, "lib", "minitest", "ask_ruby_harness_plugin.rb")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Minitest --name accepts a regexp; alternation runs exactly the
|
|
126
|
+
# failed tests.
|
|
127
|
+
def name_pattern(failed_tests)
|
|
128
|
+
escaped = failed_tests.map { |t| Regexp.escape(t[:test_name]) }
|
|
129
|
+
"/#{escaped.join('|')}/"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run(command, env, log_path, timeout)
|
|
133
|
+
# The harness server may run with a deliberately small pool
|
|
134
|
+
# (e.g. RAILS_MAX_THREADS=1 in its MCP config). Test runs are a
|
|
135
|
+
# separate concern — let them use the app's normal pool sizes and
|
|
136
|
+
# their own bundle. All BUNDLE*/BUNDLER_* vars are stripped so
|
|
137
|
+
# the child's `bundle exec` resolves the project's own Gemfile
|
|
138
|
+
# from cwd (inherited BUNDLE_GEMFILE and BUNDLER_ORIG_* sentinels
|
|
139
|
+
# would otherwise hijack it).
|
|
140
|
+
child_env = env.merge("RAILS_MAX_THREADS" => nil)
|
|
141
|
+
ENV.each_key { |k| child_env[k] = nil if k.start_with?("BUNDLE") }
|
|
142
|
+
pid = Process.spawn(child_env, *command, chdir: app_root.to_s,
|
|
143
|
+
out: [log_path.to_s, "w"], err: [:child, :out])
|
|
144
|
+
status = nil
|
|
145
|
+
timed_out = false
|
|
146
|
+
begin
|
|
147
|
+
Timeout.timeout(timeout) { status = Process.wait2(pid).last }
|
|
148
|
+
rescue Timeout::Error
|
|
149
|
+
timed_out = true
|
|
150
|
+
begin
|
|
151
|
+
Process.kill("TERM", pid)
|
|
152
|
+
sleep 0.2
|
|
153
|
+
Process.kill("KILL", pid)
|
|
154
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
155
|
+
# Process already gone — nothing to kill.
|
|
156
|
+
end
|
|
157
|
+
status = Process.wait2(pid).last rescue nil
|
|
158
|
+
end
|
|
159
|
+
{ exit_status: status&.exitstatus, timed_out: timed_out }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_report(runner, command, env, outcome, results, status_path, log_path)
|
|
163
|
+
summary = results[:summary]
|
|
164
|
+
failed_tests = results[:failed_tests]
|
|
165
|
+
persist_failed_tests(runner, failed_tests, status_path)
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
framework: runner.to_s,
|
|
169
|
+
command: command.join(" "),
|
|
170
|
+
exit_status: outcome[:exit_status],
|
|
171
|
+
timed_out: outcome[:timed_out],
|
|
172
|
+
summary: summary,
|
|
173
|
+
failed_tests: failed_tests,
|
|
174
|
+
artifact: rel(log_path),
|
|
175
|
+
next: summary[:failures] + summary[:errors] > 0 ? "run_tests(failed_only: true)" : nil
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def timed_out_report(runner, command, env, outcome, status_path, log_path)
|
|
180
|
+
persist_failed_tests(runner, [], status_path)
|
|
181
|
+
{
|
|
182
|
+
framework: runner.to_s,
|
|
183
|
+
command: command.join(" "),
|
|
184
|
+
exit_status: outcome[:exit_status],
|
|
185
|
+
timed_out: true,
|
|
186
|
+
summary: nil,
|
|
187
|
+
failed_tests: nil,
|
|
188
|
+
artifact: rel(log_path),
|
|
189
|
+
next: nil
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_results(runner, json_path)
|
|
194
|
+
return nil unless json_path.exist?
|
|
195
|
+
payload = JSON.parse(json_path.read)
|
|
196
|
+
runner == :rspec ? parse_rspec(payload) : parse_minitest(payload)
|
|
197
|
+
rescue JSON::ParserError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse_minitest(payload)
|
|
202
|
+
tests = payload["tests"] || []
|
|
203
|
+
summary = {
|
|
204
|
+
run: payload.fetch("run", tests.size),
|
|
205
|
+
failures: payload.fetch("failures", 0),
|
|
206
|
+
errors: payload.fetch("errors", 0),
|
|
207
|
+
skips: payload.fetch("skips", 0)
|
|
208
|
+
}
|
|
209
|
+
failed_tests = tests.filter_map do |t|
|
|
210
|
+
next unless %w[failed error].include?(t["status"])
|
|
211
|
+
{ file: t["file"], test_name: t["name"], line: t["line"], message: t["message"] }
|
|
212
|
+
end
|
|
213
|
+
{ summary: summary, failed_tests: failed_tests }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def parse_rspec(payload)
|
|
217
|
+
examples = payload["examples"] || []
|
|
218
|
+
summary_payload = payload["summary"] || {}
|
|
219
|
+
failed_examples = examples.select { |e| e["status"] == "failed" }
|
|
220
|
+
pending = examples.count { |e| e["status"] == "pending" }
|
|
221
|
+
summary = {
|
|
222
|
+
run: summary_payload.fetch("example_count", examples.size),
|
|
223
|
+
failures: summary_payload.fetch("failure_count", failed_examples.size),
|
|
224
|
+
errors: 0,
|
|
225
|
+
skips: summary_payload.fetch("pending_count", pending)
|
|
226
|
+
}
|
|
227
|
+
failed_tests = failed_examples.map do |e|
|
|
228
|
+
exception = e["exception"] || {}
|
|
229
|
+
{
|
|
230
|
+
file: e["file_path"],
|
|
231
|
+
test_name: e["full_description"],
|
|
232
|
+
line: e["line_number"],
|
|
233
|
+
message: exception["message"]
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
{ summary: summary, failed_tests: failed_tests }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def persist_failed_tests(runner, failed_tests, status_path)
|
|
240
|
+
status_path.write(JSON.pretty_generate(framework: runner.to_s, failed_tests: failed_tests))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def load_failed_tests
|
|
244
|
+
path = app_root.join(*ARTIFACT_DIR, "last-failures.json")
|
|
245
|
+
return [] unless path.exist?
|
|
246
|
+
JSON.parse(path.read).fetch("failed_tests", []).map { |t| symbolize_keys(t) }
|
|
247
|
+
rescue JSON::ParserError
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def symbolize_keys(hash)
|
|
252
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def rel(path)
|
|
256
|
+
path.relative_path_from(app_root).to_s
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class SchemaGraph < Ask::Ruby::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
|
+
# Eager-load the host app's models when it's a full framework app
|
|
38
|
+
# (Rails); in a plain Ruby project the models are loaded by
|
|
39
|
+
# whatever loaded them.
|
|
40
|
+
if defined?(::Rails::Application) && ::Rails.application
|
|
41
|
+
::Rails.application.eager_load! rescue nil
|
|
42
|
+
end
|
|
43
|
+
ActiveRecord::Base.descendants.reject do |klass|
|
|
44
|
+
klass.abstract_class? ||
|
|
45
|
+
klass.name.nil? ||
|
|
46
|
+
TABLE_EXCLUSIONS.include?(klass.table_name) ||
|
|
47
|
+
klass.name.start_with?("ActiveRecord::", "ActiveStorage::", "ActionText::")
|
|
48
|
+
end.sort_by(&:name)
|
|
49
|
+
rescue StandardError
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_model_details(models, detail)
|
|
54
|
+
models.filter_map do |klass|
|
|
55
|
+
name = klass.name rescue nil
|
|
56
|
+
next nil unless name
|
|
57
|
+
|
|
58
|
+
entry = {
|
|
59
|
+
name: name,
|
|
60
|
+
table_name: klass.table_name,
|
|
61
|
+
primary_key: safe_primary_key(klass)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if klass.respond_to?(:columns)
|
|
65
|
+
entry[:columns] = safe_columns(klass)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if klass.respond_to?(:reflect_on_all_associations)
|
|
69
|
+
entry[:associations] = klass.reflect_on_all_associations.group_by(&:macro).transform_values { |refs|
|
|
70
|
+
refs.map { |a|
|
|
71
|
+
assoc = { name: a.name, class_name: a.class_name }
|
|
72
|
+
assoc[:through] = a.options[:through] if a.options[:through]
|
|
73
|
+
assoc[:source] = a.options[:source] if a.options[:source]
|
|
74
|
+
assoc[:foreign_key] = a.foreign_key
|
|
75
|
+
assoc[:polymorphic] = true if a.polymorphic?
|
|
76
|
+
assoc[:as] = a.options[:as] if a.options[:as]
|
|
77
|
+
assoc[:dependent] = a.options[:dependent] if a.options[:dependent]
|
|
78
|
+
assoc
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if klass.respond_to?(:validators)
|
|
84
|
+
entry[:validators] = klass.validators.map { |v|
|
|
85
|
+
{
|
|
86
|
+
attribute: v.attributes.first&.to_s,
|
|
87
|
+
kind: v.kind,
|
|
88
|
+
options: v.options.reject { |k, _| k == :if }
|
|
89
|
+
}
|
|
90
|
+
}.reject { |v| v[:attribute].nil? }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
entry
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_association_graph(models)
|
|
98
|
+
edges = []
|
|
99
|
+
|
|
100
|
+
models.each do |klass|
|
|
101
|
+
klass.reflect_on_all_associations.each do |a|
|
|
102
|
+
next if a.macro == :has_many && a.options[:through] # Skip through associations (derived)
|
|
103
|
+
|
|
104
|
+
target_model = find_model_for_association(models, a)
|
|
105
|
+
next unless target_model
|
|
106
|
+
|
|
107
|
+
edges << {
|
|
108
|
+
from: klass.name,
|
|
109
|
+
to: target_model.name,
|
|
110
|
+
type: a.macro,
|
|
111
|
+
via: a.name,
|
|
112
|
+
foreign_key: a.foreign_key,
|
|
113
|
+
polymorphic: a.polymorphic? || false
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
edges
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def find_model_for_association(models, association)
|
|
122
|
+
class_name = association.class_name
|
|
123
|
+
# Try exact match first, then match by class name suffix (handles namespace issues)
|
|
124
|
+
models.find { |m| m.name == class_name } ||
|
|
125
|
+
models.find { |m| m.name.end_with?("::#{class_name}") } ||
|
|
126
|
+
models.find { |m| class_name.end_with?("::#{m.name}") }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_table_details(detail)
|
|
130
|
+
models = discover_ar_models
|
|
131
|
+
tables = {}
|
|
132
|
+
|
|
133
|
+
models.each do |klass|
|
|
134
|
+
table = klass.table_name
|
|
135
|
+
next if TABLE_EXCLUSIONS.include?(table)
|
|
136
|
+
|
|
137
|
+
tables[table] = {
|
|
138
|
+
model: klass.name,
|
|
139
|
+
columns: safe_columns(klass),
|
|
140
|
+
indexes: fetch_indexes_for(table)
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
tables
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def safe_primary_key(klass)
|
|
148
|
+
klass.primary_key
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def safe_columns(klass)
|
|
154
|
+
klass.columns.map { |c|
|
|
155
|
+
col = { name: c.name, type: c.type, null: c.null }
|
|
156
|
+
col[:default] = c.default unless c.default.nil?
|
|
157
|
+
col[:primary_key] = true if c.name == klass.primary_key
|
|
158
|
+
col
|
|
159
|
+
}
|
|
160
|
+
rescue StandardError
|
|
161
|
+
[]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def fetch_indexes_for(table_name)
|
|
165
|
+
ActiveRecord::Base.connection.indexes(table_name).map do |idx|
|
|
166
|
+
{
|
|
167
|
+
name: idx.name,
|
|
168
|
+
columns: idx.columns,
|
|
169
|
+
unique: idx.unique
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
rescue StandardError
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|