ask-rails-harness 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c1a8388500ab4366c876be18f2b8fb2fd2c489fcd6c04da90c2a1ee9eea8b59
4
- data.tar.gz: 6012eff1acc1cdecd8b606a91027f517a69ddb77d252c0aae8846e23b5e7f15e
3
+ metadata.gz: 3215cf3ae063b9066a65877e5eb501b3daec422ac1a6639a9286b384b2af3e8b
4
+ data.tar.gz: ba4b4347b86fd7dd944bfc5ac9b2031140081717721737cb9fcdce51badea17b
5
5
  SHA512:
6
- metadata.gz: 7492561a0888730defddcff380d3800afefab364f41bf4df2e842a1894cdc4229c08e7d9c249df0b2d8ece1d0e9e345369840434a1af10d8802eb2f9f2fac4c4
7
- data.tar.gz: 490b772093ac41af1ca679dfa55af7500ede1c634d01e82a3f7393ba23381fdf30507bf95929d8405e824ab7daf0d05f17052164a41724e8547bf7c4f3848abb
6
+ metadata.gz: 0eac35130da6e2a11713105cf07856c381f540487a3654b7385e1414920671831e568de01eafd8437286bb9537573aa42ca1287a2bfd2c2114c5cbde9ca9a9da
7
+ data.tar.gz: 7bb18027c9b38131db8d706fbdb19601449de9444b870441cd31684cb962063b08ddc78fd4f7dcbbcdbab0b977f8f81f5fe94f39ec15d0efd2e240da76278236
data/CHANGELOG.md CHANGED
@@ -1,3 +1,41 @@
1
+ ## [0.2.0] — 2026-08-10
2
+
3
+ ### Added
4
+
5
+ - **`run_tests` tool** — runs the app's test suite and returns structured
6
+ results (summary counts plus per-test file/line/message for failures)
7
+ instead of raw terminal output. Detects minitest vs rspec (`Gemfile.lock` +
8
+ `spec/`), supports `file:`/`name:` filters, `failed_only:` reruns persisted
9
+ from the previous run (`tmp/test/.ask/last-failures.json`), and a `timeout:`
10
+ that kills the run and reports `timed_out` with the artifact path. Minitest
11
+ gets machine-readable results via a bundled reporter/plugin; rspec uses its
12
+ built-in JSON formatter.
13
+ - **`MinitestJsonReporter` + minitest plugin** — `lib/minitest/ask_rails_harness_plugin.rb`
14
+ is auto-discovered by minitest and registers the JSON reporter only when
15
+ `ASK_TEST_JSON_PATH` is set (i.e. when the harness invoked the run), so
16
+ ordinary `bin/rails test` runs are untouched. Supports both the minitest 5
17
+ (`ask_rails_harness_plugin_init`) and minitest 6 (`plugin_ask_rails_harness_init`)
18
+ plugin init conventions. Full human output always lands at
19
+ `tmp/test/.ask/last-test.log`.
20
+
21
+ ### Fixed
22
+
23
+ - **`ReadLog` crashed with `NoMethodError: undefined method 'env' for module Ask::Rails`** —
24
+ `read_log.rb` used bare `Rails.env`, which resolves lexically to the
25
+ `Ask::Rails` module inside the gem's own namespace. Now `::Rails.env`
26
+ (matching `query_database.rb`).
27
+ - **Audit-log failure warnings were silently swallowed** — `audit_log.rb` used
28
+ the same bare-`Rails` lookup, so `defined?(Rails.logger)` was always false.
29
+ Now `::Rails.logger&.warn` with a nil-safe guard.
30
+
31
+ ## [0.1.1] — 2026-07-31
32
+
33
+ ### Changed
34
+
35
+ - Use `Ask::Agent::Policies::Permissions` for environment hooks (requires
36
+ ask-agent >= 0.28.0).
37
+ - Require `time` for time-stdlib methods (`iso8601`, `parse`).
38
+
1
39
  ## [0.1.0] — 2026-07-25
2
40
 
3
41
  ### Changed
data/README.md CHANGED
@@ -1,24 +1,10 @@
1
1
  # ask-rails-harness
2
2
 
3
- An admin AI agent for your Rails app. Mount the engine, get a chat interface at `/ask` that can inspect your code, query your database, read logs, and help you debug — all through an authenticated admin UI.
3
+ [![Gem Version](https://badge.fury.io/rb/ask-rails-harness.svg)](https://badge.fury.io/rb/ask-rails-harness)
4
4
 
5
- > **Previously developed as `ask-rails` (v0.1.0–0.11.1).** Renamed to `ask-rails-harness` for clarity this gem is the *harness* that wraps an AI agent with Rails-aware tools, environment, state, and feedback for internal/admin use.
5
+ Admin AI copilot for Rails apps. Mounts a Rails Engine at `/ask` with an admin chat UI and 9 Rails-aware tools that inspect your code, query your database, read logs, and help you debug. For internal/admin use only: the agent has direct access to your database, file system, and shell.
6
6
 
7
- ## Who is this for?
8
-
9
- - **Rails developers** who want an AI co-pilot that understands their app's codebase, schema, routes, and logs
10
- - **Internal/Admin use only** — the agent has direct access to your database, file system, and shell. Not for external/customer-facing use.
11
-
12
- For building customer-facing AI agents, use `ask-agent` directly with your own tools and UI.
13
-
14
- ## What it gives you
15
-
16
- - **9 Rails-aware tools**: `ReadFile`, `QueryDatabase`, `ReadRoutes`, `ReadModel`, `ReadLog`, `RunCommand`, `SearchCodebase`, `SchemaGraph`, `RouteInspector`
17
- - **Admin chat UI**: Mount the engine, get a working chat at `/ask` with SSE streaming
18
- - **Auth integration**: Protect `/ask` behind your existing Devise/authentication
19
- - **AR persistence**: Agent sessions survive server restarts
20
- - **Service discovery**: Auto-detects installed ask-\* service gems
21
- - **Skills**: Built-in guides for Rails debugging, deployment, and database performance
7
+ Previously released as `ask-rails` (v0.1.0-0.11.1) and renamed to `ask-rails-harness`. For customer-facing agents, use [ask-agent](https://github.com/ask-rb/ask-agent) directly.
22
8
 
23
9
  ## Installation
24
10
 
@@ -27,36 +13,21 @@ bundle add ask-rails-harness
27
13
  rails generate ask_rails_harness:install
28
14
  ```
29
15
 
30
- ## Quick Start
16
+ The generator creates `config/initializers/ask_rails_harness.rb`, the `ask_sessions` and `ask_audit_logs` migrations, and an `app/tools/` directory. Requires Rails 7.1+.
31
17
 
32
- Add the engine mount and auth protection to `config/routes.rb`:
18
+ Run the migration, then mount the engine in `config/routes.rb`:
33
19
 
34
20
  ```ruby
35
- # config/routes.rb
36
21
  Rails.application.routes.draw do
37
- # ... your routes ...
38
-
39
22
  authenticate :user, ->(u) { u.admin? } do
40
23
  mount Ask::Rails::Harness::Engine, at: "/ask"
41
24
  end
42
25
  end
43
26
  ```
44
27
 
45
- Then visit `/ask` in your browser.
46
-
47
- ## Usage
48
-
49
- ### Configuration
50
-
51
- ```ruby
52
- # config/initializers/ask_rails_harness.rb
53
- Ask::Rails::Harness.configure do |c|
54
- c.default_model = "claude-sonnet-4"
55
- c.max_turns = 50
56
- end
57
- ```
28
+ Visit `/ask` in your browser.
58
29
 
59
- ### Programmatic Access
30
+ ## Quick Start
60
31
 
61
32
  ```ruby
62
33
  # From any controller, view, or job
@@ -64,58 +35,59 @@ session = Ask::Rails::Harness.agent_session
64
35
  session.run("Find all open issues labeled 'bug' in our repo")
65
36
  ```
66
37
 
67
- ### Route Helpers
38
+ ## Configuration
68
39
 
69
40
  ```ruby
70
- ask_rails_harness.root_path # => /ask
71
- ask_rails_harness.sessions_path # => /ask/sessions
72
- ask_rails_harness.session_messages_path(session_id) # => /ask/sessions/:id/messages
41
+ # config/initializers/ask_rails_harness.rb
42
+ Ask::Rails::Harness.configure do |c|
43
+ c.default_model = "claude-sonnet-4"
44
+ c.max_turns = 50
45
+ c.tool_concurrency = 5
46
+ c.allowed_commands = [/^rails /]
47
+ c.denied_commands = [/rm/, /dropdb/]
48
+ c.max_session_age = 7.days
49
+ c.max_sessions = 100
50
+ c.environment :production do |env|
51
+ env.mode = :read_only
52
+ end
53
+ end
73
54
  ```
74
55
 
75
- ### Auth
56
+ Available settings: `default_model`, `max_turns`, `tool_concurrency`, `system_prompt`, `persistence_adapter`, `current_user`, `allowed_commands`, `denied_commands`, `max_session_age`, `max_sessions`, and per-environment permissions via `environment(name) { |env| env.mode = ... }`.
76
57
 
77
- By default, the admin chat is unprotected. Add auth in your routes (as shown above) or set a custom check:
58
+ ## Essential API
78
59
 
79
- ```ruby
80
- # config/initializers/ask_rails_harness.rb
81
- Ask::Rails::Harness::Auth.check = -> {
82
- redirect_to main_app.login_path unless current_user&.admin?
83
- }
84
- ```
60
+ - `Ask::Rails::Harness.agent_session`: build an agent session with the configured tools and prompt
61
+ - `Ask::Rails::Harness.configure { |c| ... }`: configuration (see above)
62
+ - `Ask::Rails::Harness.cleanup!`: prune old sessions and audit logs; also available as `rails ask_rails_harness:cleanup`
63
+ - `Ask::Rails::Harness::Persistence`: ActiveRecord session persistence backed by the `ask_sessions` table
64
+ - `Ask::Rails::Harness::Auth.check = -> { ... }`: auth proc evaluated in the controller context; the engine routes are unprotected by default
65
+ - Audit log: every tool call is recorded in `ask_audit_logs` and broadcast as the `audit_log.ask_rails_harness` ActiveSupport notification
85
66
 
86
67
  ## Tools
87
68
 
88
69
  | Tool | What it does |
89
70
  |---|---|
90
- | `ReadFile` | Read any file (relative to `Rails.root`) |
91
- | `QueryDatabase` | Run read-only SQL (rejects non-SELECT in production) |
92
- | `ReadModel` | Inspect AR model schema, associations, validations |
93
- | `ReadRoutes` | View `config/routes.rb` |
71
+ | `ReadFile` | Read any file relative to `Rails.root` |
72
+ | `QueryDatabase` | Read-only SQL (non-SELECT rejected in production) |
73
+ | `ReadModel` | Inspect an ActiveRecord model's columns, associations, validations |
74
+ | `ReadRoutes` | Read `config/routes.rb` |
94
75
  | `ReadLog` | Read log files with level/search filtering |
95
76
  | `RunCommand` | Run shell commands in the app root |
96
- | `SearchCodebase` | Grep the codebase for patterns |
97
- | `SchemaGraph` | Full schema introspection — all models, tables, columns, associations |
77
+ | `SearchCodebase` | Grep the codebase |
78
+ | `SchemaGraph` | Full schema introspection: models, tables, columns, associations |
98
79
  | `RouteInspector` | Parsed route table with filters |
99
80
 
100
- ## Engine Routes
81
+ ## Full documentation
101
82
 
102
- ```
103
- GET /ask → Chat UI
104
- POST /ask/sessions → Create new session
105
- POST /ask/sessions/:id/messages → Send message (SSE streamed response)
106
- GET /ask/sessions/:id/messages → Get message history
107
- GET /ask/sessions/:id/stream → SSE stream for existing session
108
- ```
83
+ The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [Rails setup](https://ask-rb.github.io/ask-docs/rails/setup) covers ask-rails-harness in depth. API reference: https://ask-rb.github.io/ask-docs/reference/api.
109
84
 
110
- ## Compared to ask-agent
85
+ ## Development
111
86
 
112
- | `ask-agent` | `ask-rails-harness` |
113
- |---|---|
114
- | Build external-facing agents | Build an internal admin co-pilot |
115
- | Bring your own tools | Ships Rails-specific tools |
116
- | Bring your own UI | Ships an admin chat UI |
117
- | Any Ruby app | Rails apps only |
118
- | General purpose | Development, debugging, ops |
87
+ ```bash
88
+ bundle install
89
+ bundle exec rake test
90
+ ```
119
91
 
120
92
  ## License
121
93
 
@@ -166,8 +166,10 @@ module Ask
166
166
  VALUES (#{values.join(', ')})"
167
167
  )
168
168
  rescue StandardError => e
169
- # Silently fail — audit log should never crash the caller
170
- Rails.logger.warn("[ask-rails-harness] Audit log write failed: #{e.message}") if defined?(Rails.logger)
169
+ # Silently fail — audit log should never crash the caller.
170
+ # ::Rails avoids the bare-`Rails` constant resolving to Ask::Rails;
171
+ # `&.` guards against Rails.logger returning nil (no app booted).
172
+ ::Rails.logger&.warn("[ask-rails-harness] Audit log write failed: #{e.message}") if defined?(::Rails.logger)
171
173
  end
172
174
 
173
175
  # Reset cached table check (useful in tests)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "json"
5
+
6
+ module Ask
7
+ module Rails
8
+ module Harness
9
+ # Writes a machine-readable JSON summary of a minitest run.
10
+ #
11
+ # Registered by the ask_rails_harness minitest plugin when the
12
+ # ASK_TEST_JSON_PATH env var is set (see the run_tests tool). Produces
13
+ # stable structured results — test name, klass, source file/line, status,
14
+ # and a message head — so the harness never has to parse terminal output.
15
+ #
16
+ # The reporter is inert unless ASK_TEST_JSON_PATH is set, so ordinary
17
+ # `bin/rails test` runs in apps that ship the harness are unaffected.
18
+ class MinitestJsonReporter < Minitest::AbstractReporter
19
+ def initialize(path = ENV["ASK_TEST_JSON_PATH"])
20
+ super()
21
+ @path = path
22
+ @results = []
23
+ end
24
+
25
+ def record(result)
26
+ @results << result
27
+ end
28
+
29
+ def report
30
+ return if @path.nil? || @path.to_s.empty?
31
+ File.write(@path, JSON.pretty_generate(build_report))
32
+ end
33
+
34
+ # Never influences the process exit status — pass/fail is decided by
35
+ # minitest's own summary reporter.
36
+ def passed?
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def build_report
43
+ tests = @results.map { |result| test_entry(result) }
44
+ {
45
+ "framework" => "minitest",
46
+ "run" => tests.size,
47
+ "failures" => tests.count { |t| t["status"] == "failed" },
48
+ "errors" => tests.count { |t| t["status"] == "error" },
49
+ "skips" => tests.count { |t| t["status"] == "skipped" },
50
+ "tests" => tests
51
+ }
52
+ end
53
+
54
+ def test_entry(result)
55
+ file, line = result.source_location
56
+ failure = result.failure
57
+ {
58
+ "name" => result.name,
59
+ "klass" => result.klass,
60
+ "file" => file,
61
+ "line" => line,
62
+ "time" => result.time,
63
+ "status" => status_of(result),
64
+ "message" => failure ? message_of(failure) : nil
65
+ }
66
+ end
67
+
68
+ def status_of(result)
69
+ return "skipped" if result.skipped?
70
+ return "error" if result.error?
71
+ return "passed" if result.passed?
72
+ "failed"
73
+ end
74
+
75
+ # First lines of the failure message — enough for an agent to act on
76
+ # without dumping full backtraces into the report.
77
+ def message_of(failure)
78
+ failure.message.to_s.lines.first(3).map(&:strip).reject(&:empty?).join("\n")
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "time"
2
3
 
3
4
  module Ask
4
5
  module Rails
@@ -50,7 +50,9 @@ module Ask
50
50
 
51
51
  def resolve_log_path(custom_path)
52
52
  return rails_root.join(custom_path) if custom_path
53
- rails_root.join("log", "#{Rails.env}.log")
53
+ # Use ::Rails (not Rails) — bare `Rails` inside the Ask::Rails::*
54
+ # namespace resolves to the Ask::Rails module itself.
55
+ rails_root.join("log", "#{::Rails.env}.log")
54
56
  end
55
57
 
56
58
  # Read from rotated archives too: log/production.log, .1, .2.gz, etc.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ask
7
+ module Rails
8
+ module Harness
9
+ module Tools
10
+ class RunTests < Ask::Rails::Harness::Tool
11
+ description "Run the app's test suite and return structured results — summary counts " \
12
+ "plus per-test file/line/message for failures, never raw terminal output. " \
13
+ "Minitest gets a JSON reporter via the bundled minitest plugin; rspec uses " \
14
+ "its built-in JSON formatter. Rerun only the previous run's failures with " \
15
+ "failed_only."
16
+
17
+ param :file, type: :string, desc: "Test file path(s) relative to the app 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
+ framework = detect_framework
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 = rails_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(framework, files, name, failed_tests, json_path)
40
+ outcome = run(command, env, log_path, timeout)
41
+
42
+ results = parse_results(framework, 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(framework, 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 (#{framework}); " \
50
+ "full output at #{rel(log_path)}"
51
+ )
52
+ end
53
+
54
+ report = build_report(framework, 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
+ # Prefer rspec when it's in the bundle AND a spec/ dir exists;
66
+ # otherwise assume minitest (the Rails default).
67
+ def detect_framework
68
+ lockfile = rails_root.join("Gemfile.lock")
69
+ rspec = lockfile.exist? && lockfile.read.include?("rspec-rails")
70
+ rspec && rails_root.join("spec").directory? ? :rspec : :minitest
71
+ end
72
+
73
+ def build_command(framework, files, name, failed_tests, json_path)
74
+ if framework == :rspec
75
+ args = ["bundle", "exec", "rspec"]
76
+ args.concat(files)
77
+ args.concat(failed_tests.map { |t| "#{t[:file]}:#{t[:line]}" }) if failed_tests
78
+ args.concat(["-e", name]) if name
79
+ args.concat(["--format", "json", "--out", json_path.to_s])
80
+ [args, {}]
81
+ else
82
+ # Rails' `rails test` passes CLI args straight to minitest (files
83
+ # are required by Rails' runner, options parsed by minitest). The
84
+ # JSON reporter is injected via RUBYOPT: minitest 6 dropped both
85
+ # plugin auto-discovery and the -r option, so the plugin file is
86
+ # required by absolute path at process start (it pushes its
87
+ # extension; init_plugins registers the reporter later).
88
+ args = ["bin/rails", "test"]
89
+ args.concat(files)
90
+ args.concat(["-n", name]) if name
91
+ args.concat(["-n", name_pattern(failed_tests)]) if failed_tests
92
+ # minitest 6 dropped both plugin auto-discovery and the -r
93
+ # option. Activate the app's bundle first, then require the
94
+ # plugin by absolute path — it pushes its extension, and
95
+ # init_plugins registers the JSON reporter later.
96
+ env = {
97
+ "ASK_TEST_JSON_PATH" => json_path.to_s,
98
+ "RUBYOPT" => "-rbundler/setup -r#{minitest_plugin_path}"
99
+ }
100
+ [args, env]
101
+ end
102
+ end
103
+
104
+ def minitest_plugin_path
105
+ spec = Gem.loaded_specs["ask-rails-harness"]
106
+ spec ||= Gem::Specification.find_by_name("ask-rails-harness")
107
+ File.join(spec.full_gem_path, "lib", "minitest", "ask_rails_harness_plugin.rb")
108
+ end
109
+
110
+ # Minitest --name accepts a regexp; alternation runs exactly the
111
+ # failed tests.
112
+ def name_pattern(failed_tests)
113
+ escaped = failed_tests.map { |t| Regexp.escape(t[:test_name]) }
114
+ "/#{escaped.join('|')}/"
115
+ end
116
+
117
+ def run(command, env, log_path, timeout)
118
+ pid = Process.spawn(env, *command, chdir: rails_root.to_s,
119
+ out: [log_path.to_s, "w"], err: [:child, :out])
120
+ status = nil
121
+ timed_out = false
122
+ begin
123
+ Timeout.timeout(timeout) { status = Process.wait2(pid).last }
124
+ rescue Timeout::Error
125
+ timed_out = true
126
+ begin
127
+ Process.kill("TERM", pid)
128
+ sleep 0.2
129
+ Process.kill("KILL", pid)
130
+ rescue Errno::ESRCH, Errno::EPERM
131
+ # Process already gone — nothing to kill.
132
+ end
133
+ status = Process.wait2(pid).last rescue nil
134
+ end
135
+ { exit_status: status&.exitstatus, timed_out: timed_out }
136
+ end
137
+
138
+ def build_report(framework, command, env, outcome, results, status_path, log_path)
139
+ summary = results[:summary]
140
+ failed_tests = results[:failed_tests]
141
+ persist_failed_tests(framework, failed_tests, status_path)
142
+
143
+ {
144
+ framework: framework.to_s,
145
+ command: full_command(command, env),
146
+ exit_status: outcome[:exit_status],
147
+ timed_out: outcome[:timed_out],
148
+ summary: summary,
149
+ failed_tests: failed_tests,
150
+ artifact: rel(log_path),
151
+ next: summary[:failures] + summary[:errors] > 0 ? "run_tests(failed_only: true)" : nil
152
+ }
153
+ end
154
+
155
+ def timed_out_report(framework, command, env, outcome, status_path, log_path)
156
+ persist_failed_tests(framework, [], status_path)
157
+ {
158
+ framework: framework.to_s,
159
+ command: full_command(command, env),
160
+ exit_status: outcome[:exit_status],
161
+ timed_out: true,
162
+ summary: nil,
163
+ failed_tests: nil,
164
+ artifact: rel(log_path),
165
+ next: nil
166
+ }
167
+ end
168
+
169
+ # The minitest options are part of the command itself (Rails passes
170
+ # CLI args through to minitest), so the reported command is the full
171
+ # invocation.
172
+ def full_command(command, env)
173
+ command.join(" ")
174
+ end
175
+
176
+ def parse_results(framework, json_path)
177
+ return nil unless json_path.exist?
178
+ payload = JSON.parse(json_path.read)
179
+ framework == :rspec ? parse_rspec(payload) : parse_minitest(payload)
180
+ rescue JSON::ParserError
181
+ nil
182
+ end
183
+
184
+ def parse_minitest(payload)
185
+ tests = payload["tests"] || []
186
+ summary = {
187
+ run: payload.fetch("run", tests.size),
188
+ failures: payload.fetch("failures", 0),
189
+ errors: payload.fetch("errors", 0),
190
+ skips: payload.fetch("skips", 0)
191
+ }
192
+ failed_tests = tests.filter_map do |t|
193
+ next unless %w[failed error].include?(t["status"])
194
+ { file: t["file"], test_name: t["name"], line: t["line"], message: t["message"] }
195
+ end
196
+ { summary: summary, failed_tests: failed_tests }
197
+ end
198
+
199
+ def parse_rspec(payload)
200
+ examples = payload["examples"] || []
201
+ summary_payload = payload["summary"] || {}
202
+ failed_examples = examples.select { |e| e["status"] == "failed" }
203
+ pending = examples.count { |e| e["status"] == "pending" }
204
+ summary = {
205
+ run: summary_payload.fetch("example_count", examples.size),
206
+ failures: summary_payload.fetch("failure_count", failed_examples.size),
207
+ errors: 0,
208
+ skips: summary_payload.fetch("pending_count", pending)
209
+ }
210
+ failed_tests = failed_examples.map do |e|
211
+ exception = e["exception"] || {}
212
+ {
213
+ file: e["file_path"],
214
+ test_name: e["full_description"],
215
+ line: e["line_number"],
216
+ message: exception["message"]
217
+ }
218
+ end
219
+ { summary: summary, failed_tests: failed_tests }
220
+ end
221
+
222
+ def persist_failed_tests(framework, failed_tests, status_path)
223
+ status_path.write(JSON.pretty_generate(framework: framework.to_s, failed_tests: failed_tests))
224
+ end
225
+
226
+ def load_failed_tests
227
+ path = rails_root.join(*ARTIFACT_DIR, "last-failures.json")
228
+ return [] unless path.exist?
229
+ JSON.parse(path.read).fetch("failed_tests", []).map { |t| symbolize_keys(t) }
230
+ rescue JSON::ParserError
231
+ []
232
+ end
233
+
234
+ def symbolize_keys(hash)
235
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
236
+ end
237
+
238
+ def rel(path)
239
+ path.relative_path_from(rails_root).to_s
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Rails
5
5
  module Harness
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
@@ -3,6 +3,7 @@
3
3
  require "rails"
4
4
  require "ask/agent"
5
5
  require "ask/auth"
6
+ require "time"
6
7
 
7
8
  module Ask
8
9
  module Rails
@@ -64,7 +65,7 @@ module Ask
64
65
  env_mode = configuration.effective_mode
65
66
  return {} unless env_mode
66
67
 
67
- perms = Ask::Agent::Extensions::Permissions.new(mode: env_mode)
68
+ perms = Ask::Agent::Policies::Permissions.new(mode: env_mode)
68
69
  { before_tool: [perms.method(:before_tool_call)] }
69
70
  rescue ArgumentError => e
70
71
  warn "[ask-rails-harness] Invalid environment mode: #{e.message}"
@@ -173,6 +174,8 @@ require_relative "harness/tools/read_model"
173
174
  require_relative "harness/tools/read_log"
174
175
  require_relative "harness/tools/schema_graph"
175
176
  require_relative "harness/tools/route_inspector"
177
+ require_relative "harness/tools/run_tests"
178
+ require_relative "harness/minitest_json_reporter"
176
179
 
177
180
  # Railtie is loaded only when Rails is fully available
178
181
  if defined?(::Rails::Railtie)
@@ -185,5 +188,5 @@ Ask::Rails::Harness::CORE_RAILS_TOOLS = [
185
188
  Ask::Rails::Harness::Tools::SearchCodebase, Ask::Rails::Harness::Tools::ReadRoutes,
186
189
  Ask::Rails::Harness::Tools::QueryDatabase, Ask::Rails::Harness::Tools::ReadModel,
187
190
  Ask::Rails::Harness::Tools::ReadLog, Ask::Rails::Harness::Tools::SchemaGraph,
188
- Ask::Rails::Harness::Tools::RouteInspector
191
+ Ask::Rails::Harness::Tools::RouteInspector, Ask::Rails::Harness::Tools::RunTests
189
192
  ].freeze
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minitest plugin for ask-rails-harness.
4
+ #
5
+ # Registers the JSON reporter when a run was started by the harness's
6
+ # run_tests tool (ASK_TEST_JSON_PATH set); ordinary test runs are untouched.
7
+ #
8
+ # Loading differs by minitest version:
9
+ # - minitest 5 auto-discovers `minitest/*_plugin.rb` files and calls
10
+ # init_plugins with `#{name}_plugin_init`.
11
+ # - minitest 6 dropped auto-discovery (and the -r option); the run_tests
12
+ # tool injects this file via RUBYOPT="-r <absolute path>" and
13
+ # init_plugins dispatches `plugin_#{name}_init` instead.
14
+ # Either way, pushing the extension below (idempotent) makes init_plugins
15
+ # dispatch to the matching init method after the composite reporter exists —
16
+ # the only reliable point to append a reporter.
17
+ # __dir__-based requires keep the file loadable before Bundler.setup.
18
+ require File.expand_path("../ask/rails/harness/minitest_json_reporter", __dir__)
19
+
20
+ module Minitest
21
+ # Minitest 6 convention: init_plugins calls plugin_#{name}_init.
22
+ def self.plugin_ask_rails_harness_init(options)
23
+ register_ask_rails_harness_json_reporter(options)
24
+ end
25
+
26
+ # Minitest 5 convention: init_plugins calls #{name}_plugin_init.
27
+ def self.ask_rails_harness_plugin_init(options)
28
+ register_ask_rails_harness_json_reporter(options)
29
+ end
30
+
31
+ def self.register_ask_rails_harness_json_reporter(_options)
32
+ path = ENV["ASK_TEST_JSON_PATH"]
33
+ return if path.nil? || path.to_s.empty?
34
+
35
+ Minitest.reporter << Ask::Rails::Harness::MinitestJsonReporter.new(path)
36
+ end
37
+ end
38
+
39
+ Minitest.extensions << "ask_rails_harness" unless Minitest.extensions.include?("ask_rails_harness")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails-harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '0.1'
60
+ version: 0.28.0
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '0.1'
67
+ version: 0.28.0
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: ask-auth
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -160,6 +160,7 @@ files:
160
160
  - lib/ask/rails/harness/configuration.rb
161
161
  - lib/ask/rails/harness/engine.rb
162
162
  - lib/ask/rails/harness/environment_permissions.rb
163
+ - lib/ask/rails/harness/minitest_json_reporter.rb
163
164
  - lib/ask/rails/harness/persistence.rb
164
165
  - lib/ask/rails/harness/railtie.rb
165
166
  - lib/ask/rails/harness/service_discovery.rb
@@ -171,6 +172,7 @@ files:
171
172
  - lib/ask/rails/harness/tools/read_routes.rb
172
173
  - lib/ask/rails/harness/tools/route_inspector.rb
173
174
  - lib/ask/rails/harness/tools/run_command.rb
175
+ - lib/ask/rails/harness/tools/run_tests.rb
174
176
  - lib/ask/rails/harness/tools/schema_graph.rb
175
177
  - lib/ask/rails/harness/tools/search_codebase.rb
176
178
  - lib/ask/rails/harness/version.rb
@@ -181,6 +183,7 @@ files:
181
183
  - lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb
182
184
  - lib/generators/ask/rails/harness/install/templates/initializer.rb
183
185
  - lib/generators/ask/rails/harness/install/templates/migration.rb
186
+ - lib/minitest/ask_rails_harness_plugin.rb
184
187
  homepage: https://github.com/ask-rb/ask-rails-harness
185
188
  licenses:
186
189
  - MIT