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.
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Ruby
5
+ module Harness
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end