debug-agent 0.5.0 → 0.6.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 +4 -4
- data/README.md +62 -5
- data/lib/debug_agent/inspectors/config_inspector.rb +137 -0
- data/lib/debug_agent/inspectors/endpoint_test.rb +284 -0
- data/lib/debug_agent/inspectors/feature_flags.rb +215 -0
- data/lib/debug_agent/inspectors/locks.rb +343 -0
- data/lib/debug_agent/inspectors/migration.rb +150 -0
- data/lib/debug_agent/inspectors/pool_inspector.rb +320 -0
- data/lib/debug_agent/version.rb +1 -1
- data/lib/debug_agent.rb +6 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b9c945dc45966ff8c9d06b0679357d54ba9dc443d5ee68c532ae1058878c6e4
|
|
4
|
+
data.tar.gz: a986e568c59ca3f8d04f71d647ba0649bbbff7659f021eca349bc3cce3b3b044
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 90d75283c6f362c3be78a419923c26d622aa6d55ed3d29ebfca1c40341d7d264a9a6074fd30e93140fb13db44991a8a52261d0695a2df22bcf1e17e865f793bb
|
|
7
|
+
data.tar.gz: 319d197cd23d9c9fd15845dc4a8a896b2560d3da83b85ac69e9687ba4ce3495ed1b720089ce82cc6adef8497e570177078f9430191bbca260a32a230f68171aa
|
data/README.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
# Ruby Debug Agent
|
|
2
2
|
|
|
3
3
|
[](https://github.com/topcheer/ruby-debug-agent)
|
|
4
|
-

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
6
8
|
|
|
7
|
-
An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, Redis, Rails models/routes, Sidekiq queues, Puma stats, fibers/signals, process info, HTTP requests, and more — **
|
|
9
|
+
An AI-powered runtime debugging agent that embeds directly into your Ruby application. Add one gem, configure an LLM key, and chat with your live app at `/agent` to inspect GC, ObjectSpace, threads, routes, Redis, Rails models/routes, Sidekiq queues, Puma stats, fibers/signals, process info, HTTP requests, and more — **84 diagnostic tools across 31 inspectors**.
|
|
10
|
+
|
|
11
|
+
## Version Support
|
|
12
|
+
|
|
13
|
+
| Ruby Version | Status |
|
|
14
|
+
|--------------|--------|
|
|
15
|
+
| 2.6 | Not supported |
|
|
16
|
+
| 2.7 | Minimum supported |
|
|
17
|
+
| 3.0 | Supported (Fiber.list available) |
|
|
18
|
+
| 3.1 | Supported |
|
|
19
|
+
| 3.2 | Supported |
|
|
20
|
+
| 3.3 | Supported |
|
|
21
|
+
| 3.4 | Tested |
|
|
22
|
+
|
|
23
|
+
> Requires Ruby 2.7+ for pattern matching guards. Framework inspectors (Rails, Sidekiq, Puma) are optional and auto-detected via `defined?`.
|
|
8
24
|
|
|
9
25
|
## Quick Start
|
|
10
26
|
|
|
@@ -55,10 +71,10 @@ http://localhost:4567/agent
|
|
|
55
71
|
- **Context compression** — automatically summarizes old conversation when token limit is approached
|
|
56
72
|
- **Dark-themed chat UI** with full markdown rendering (tables, code blocks, lists)
|
|
57
73
|
- **Max tool rounds** (25) with forced final summary when limit is reached
|
|
58
|
-
- **
|
|
74
|
+
- **84 diagnostic tools** across **31 inspectors**
|
|
59
75
|
- Zero external dependencies (no Datadog, no Grafana, no APM)
|
|
60
76
|
|
|
61
|
-
## Inspectors & Tools (
|
|
77
|
+
## Inspectors & Tools (84)
|
|
62
78
|
|
|
63
79
|
### GC Inspector
|
|
64
80
|
| Tool | Description |
|
|
@@ -193,6 +209,47 @@ http://localhost:4567/agent
|
|
|
193
209
|
|------|-------------|
|
|
194
210
|
| `get_concurrent_state` | Ruby concurrency primitives state (Mutex, ConditionVariable, Queue) |
|
|
195
211
|
|
|
212
|
+
### Deadlock & Lock Contention Inspector (v0.6.0)
|
|
213
|
+
| Tool | Description |
|
|
214
|
+
|------|-------------|
|
|
215
|
+
| `get_lock_contention` | Mutex contention stats (wait time, hold time, acquisition count) |
|
|
216
|
+
| `detect_deadlock` | Analyze all threads for deadlock patterns (circular wait detection) |
|
|
217
|
+
| `get_mutex_stats` | Per-lock statistics: total acquisitions, contentions, average wait time |
|
|
218
|
+
|
|
219
|
+
### Database Migration Inspector (v0.6.0)
|
|
220
|
+
| Tool | Description |
|
|
221
|
+
|------|-------------|
|
|
222
|
+
| `get_migration_status` | Current schema version, applied count, last migration applied |
|
|
223
|
+
| `get_pending_migrations` | Migrations not yet applied (version, description, dependencies) |
|
|
224
|
+
| `get_migration_history` | Applied migration history (version, applied_at, duration_ms) |
|
|
225
|
+
|
|
226
|
+
### Configuration Inspector (v0.6.0)
|
|
227
|
+
| Tool | Description |
|
|
228
|
+
|------|-------------|
|
|
229
|
+
| `get_config_snapshot` | All registered config values (sensitive keys masked) |
|
|
230
|
+
| `get_env_vars_masked` | Process environment variables with secret values redacted |
|
|
231
|
+
| `get_config_sources` | Config source hierarchy (env, file, defaults) with effective values |
|
|
232
|
+
|
|
233
|
+
### Feature Flags Inspector (v0.6.0)
|
|
234
|
+
| Tool | Description |
|
|
235
|
+
|------|-------------|
|
|
236
|
+
| `get_feature_flags` | List all registered feature flags with current state |
|
|
237
|
+
| `evaluate_feature_flag` | Evaluate a specific flag for a given context/user |
|
|
238
|
+
|
|
239
|
+
### Endpoint Testing Inspector (v0.6.0)
|
|
240
|
+
| Tool | Description |
|
|
241
|
+
|------|-------------|
|
|
242
|
+
| `test_endpoint` | Make an HTTP request to own app, return full response (status, headers, body) |
|
|
243
|
+
| `batch_test_endpoints` | Test multiple endpoints in one call with aggregated results |
|
|
244
|
+
| `get_endpoint_coverage` | Compare registered routes vs tested endpoints (coverage report) |
|
|
245
|
+
|
|
246
|
+
### Connection Pool Inspector (v0.6.0)
|
|
247
|
+
| Tool | Description |
|
|
248
|
+
|------|-------------|
|
|
249
|
+
| `get_pool_details` | Detailed DB pool stats (pool size, active, idle, waiting, max) |
|
|
250
|
+
| `detect_pool_leaks` | Heuristic leak detection (growing pool, high wait ratio, saturation) |
|
|
251
|
+
| `get_pool_wait_stats` | Connection acquire wait stats (avg, P95, max wait, timeout count) |
|
|
252
|
+
|
|
196
253
|
## Custom Tools
|
|
197
254
|
|
|
198
255
|
```ruby
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
|
|
3
|
+
module DebugAgent
|
|
4
|
+
# Register configuration hashes for inspection.
|
|
5
|
+
#
|
|
6
|
+
# DebugAgent.register_config(:app, {
|
|
7
|
+
# app_name: 'MyApp',
|
|
8
|
+
# port: 4567,
|
|
9
|
+
# api_key: 'secret123'
|
|
10
|
+
# })
|
|
11
|
+
@registered_configs = {}
|
|
12
|
+
|
|
13
|
+
SENSITIVE_KEY_PATTERN = /password|secret|token|api.?key|private.?key|credential/i
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :registered_configs
|
|
17
|
+
|
|
18
|
+
def register_config(name, config_hash, source: 'registered')
|
|
19
|
+
@registered_configs[name.to_s] = {
|
|
20
|
+
values: config_hash,
|
|
21
|
+
source: source,
|
|
22
|
+
registered_at: Time.now.iso8601
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def mask_sensitive(key, value)
|
|
31
|
+
return value unless value.is_a?(String) || value.is_a?(Symbol)
|
|
32
|
+
return '***' if key.to_s =~ SENSITIVE_KEY_PATTERN
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def mask_config_hash(hash)
|
|
37
|
+
hash.map do |k, v|
|
|
38
|
+
if v.is_a?(Hash)
|
|
39
|
+
[k, mask_config_hash(v)]
|
|
40
|
+
else
|
|
41
|
+
[k, mask_sensitive(k, v)]
|
|
42
|
+
end
|
|
43
|
+
end.to_h
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
register_tool('get_config_snapshot',
|
|
48
|
+
'Get all registered configuration values. Sensitive keys (password, secret, ' \
|
|
49
|
+
'token, api_key, etc.) are automatically masked') do
|
|
50
|
+
if registered_configs.empty?
|
|
51
|
+
next { error: 'No configs registered. Call DebugAgent.register_config(:name, hash).' }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
configs = registered_configs.map do |name, entry|
|
|
55
|
+
{
|
|
56
|
+
name: name,
|
|
57
|
+
source: entry[:source],
|
|
58
|
+
registered_at: entry[:registered_at],
|
|
59
|
+
values: mask_config_hash(entry[:values] || {}),
|
|
60
|
+
key_count: (entry[:values] || {}).size,
|
|
61
|
+
masked_keys: (entry[:values] || {}).keys.select { |k| k.to_s =~ SENSITIVE_KEY_PATTERN }
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
total_configs: configs.size,
|
|
67
|
+
configs: configs
|
|
68
|
+
}
|
|
69
|
+
rescue => e
|
|
70
|
+
{ error: e.message }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
register_tool('get_env_vars',
|
|
74
|
+
'Dump environment variables (ENV) with optional prefix filter. ' \
|
|
75
|
+
'Sensitive values are automatically masked',
|
|
76
|
+
prefix: { type: 'string', description: 'Only return vars starting with this prefix (e.g. APP_, RAILS_)', required: false }) do |prefix: nil|
|
|
77
|
+
vars = ENV.to_h
|
|
78
|
+
|
|
79
|
+
if prefix && !prefix.to_s.empty?
|
|
80
|
+
vars = vars.select { |k, _| k.start_with?(prefix.to_s) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
masked = {}
|
|
84
|
+
sensitive_count = 0
|
|
85
|
+
vars.each do |k, v|
|
|
86
|
+
if k =~ SENSITIVE_KEY_PATTERN
|
|
87
|
+
masked[k] = '***'
|
|
88
|
+
sensitive_count += 1
|
|
89
|
+
else
|
|
90
|
+
masked[k] = v
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
total_vars: masked.size,
|
|
96
|
+
sensitive_masked: sensitive_count,
|
|
97
|
+
prefix_filter: prefix,
|
|
98
|
+
env_vars: masked
|
|
99
|
+
}
|
|
100
|
+
rescue => e
|
|
101
|
+
{ error: e.message }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
register_tool('get_config_sources',
|
|
105
|
+
'Configuration provenance: shows where each registered config comes from ' \
|
|
106
|
+
'(environment, file, default, or registered)') do
|
|
107
|
+
if registered_configs.empty?
|
|
108
|
+
next { error: 'No configs registered. Call DebugAgent.register_config(:name, hash).' }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sources = registered_configs.map do |name, entry|
|
|
112
|
+
{
|
|
113
|
+
name: name,
|
|
114
|
+
source: entry[:source],
|
|
115
|
+
registered_at: entry[:registered_at],
|
|
116
|
+
keys: (entry[:values] || {}).keys
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Also show ENV as a config source
|
|
121
|
+
env_config_count = ENV.size
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
registered_config_sources: sources,
|
|
125
|
+
total_sources: sources.size,
|
|
126
|
+
env_var_count: env_config_count,
|
|
127
|
+
summary: {
|
|
128
|
+
registered: sources.count { |s| s[:source] == 'registered' },
|
|
129
|
+
file: sources.count { |s| s[:source] == 'file' },
|
|
130
|
+
env: sources.count { |s| s[:source] == 'env' },
|
|
131
|
+
default: sources.count { |s| s[:source] == 'default' }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
rescue => e
|
|
135
|
+
{ error: e.message }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module DebugAgent
|
|
7
|
+
@tested_routes = []
|
|
8
|
+
@tested_routes_lock = Mutex.new
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :tested_routes
|
|
12
|
+
|
|
13
|
+
def record_tested_route(method, path, status, duration_ms)
|
|
14
|
+
@tested_routes_lock.synchronize do
|
|
15
|
+
@tested_routes << {
|
|
16
|
+
method: method,
|
|
17
|
+
path: path,
|
|
18
|
+
status: status,
|
|
19
|
+
duration_ms: duration_ms,
|
|
20
|
+
tested_at: Time.now.iso8601
|
|
21
|
+
}
|
|
22
|
+
@tested_routes.shift if @tested_routes.size > 500
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_tested_routes
|
|
27
|
+
@tested_routes_lock.synchronize { @tested_routes.clear }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def app_port
|
|
35
|
+
app = DebugAgent.app
|
|
36
|
+
if app
|
|
37
|
+
app_class = app.is_a?(Class) ? app : app.class
|
|
38
|
+
if app_class.respond_to?(:port)
|
|
39
|
+
return app_class.port
|
|
40
|
+
end
|
|
41
|
+
if app_class.respond_to?(:settings) && app_class.settings.respond_to?(:port)
|
|
42
|
+
return app_class.settings.port
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
# Common defaults
|
|
46
|
+
ENV['PORT']&.to_i || 4567
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def app_host
|
|
50
|
+
'localhost'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def perform_http_request(method, path, headers, body)
|
|
54
|
+
port = app_port
|
|
55
|
+
host = app_host
|
|
56
|
+
|
|
57
|
+
uri = URI("http://#{host}:#{port}#{path}")
|
|
58
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
59
|
+
http.read_timeout = 30
|
|
60
|
+
http.open_timeout = 10
|
|
61
|
+
|
|
62
|
+
request_method = Net::HTTP.const_get(method.capitalize)
|
|
63
|
+
req = request_method.new(uri.request_uri)
|
|
64
|
+
|
|
65
|
+
# Set headers
|
|
66
|
+
headers&.each do |k, v|
|
|
67
|
+
req[k.to_s] = v.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Set body if provided
|
|
71
|
+
if body && !body.to_s.empty?
|
|
72
|
+
req['Content-Type'] ||= 'application/json'
|
|
73
|
+
req.body = body.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
response = http.request(req)
|
|
78
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
79
|
+
|
|
80
|
+
resp_body = response.body
|
|
81
|
+
parsed_body = nil
|
|
82
|
+
begin
|
|
83
|
+
parsed_body = JSON.parse(resp_body) if resp_body && !resp_body.empty?
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
parsed_body = resp_body
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
status: response.code.to_i,
|
|
90
|
+
status_message: response.message,
|
|
91
|
+
headers: response.each_header.to_h,
|
|
92
|
+
body: parsed_body,
|
|
93
|
+
duration_ms: duration,
|
|
94
|
+
method: method,
|
|
95
|
+
path: path,
|
|
96
|
+
url: uri.to_s
|
|
97
|
+
}
|
|
98
|
+
rescue Errno::ECONNREFUSED
|
|
99
|
+
{ error: "Connection refused — app not running on #{host}:#{app_port}" }
|
|
100
|
+
rescue => e
|
|
101
|
+
{ error: "Request failed: #{e.message}", method: method, path: path }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
register_tool('test_endpoint',
|
|
106
|
+
'Send an HTTP request to your own running app. Returns status, headers, body, ' \
|
|
107
|
+
'and duration. Useful for testing API endpoints from the debug agent',
|
|
108
|
+
method: { type: 'string', description: 'HTTP method: GET, POST, PUT, DELETE, PATCH', required: true },
|
|
109
|
+
path: { type: 'string', description: 'Request path (e.g. /api/orders)', required: true },
|
|
110
|
+
headers: { type: 'object', description: 'Optional HTTP headers as key-value pairs (e.g. {"X-API-Key": "demo-key-12345"})', required: false },
|
|
111
|
+
body: { type: 'string', description: 'Optional request body (JSON string for POST/PUT)', required: false }) do |method:, path:, headers: nil, body: nil|
|
|
112
|
+
method_up = method.to_s.upcase
|
|
113
|
+
unless %w[GET POST PUT DELETE PATCH HEAD OPTIONS].include?(method_up)
|
|
114
|
+
next { error: "Unsupported HTTP method: #{method}. Use GET, POST, PUT, DELETE, PATCH, HEAD, or OPTIONS." }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
result = perform_http_request(method_up, path, headers, body)
|
|
118
|
+
|
|
119
|
+
if result[:error]
|
|
120
|
+
next result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
record_tested_route(method_up, path, result[:status], result[:duration_ms])
|
|
124
|
+
|
|
125
|
+
result
|
|
126
|
+
rescue => e
|
|
127
|
+
{ error: e.message }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
register_tool('batch_test_endpoints',
|
|
131
|
+
'Run multiple endpoint tests with assertions. Each test specifies method, path, ' \
|
|
132
|
+
'optional headers/body, and optional assertions (expected_status, expected_body_contains)',
|
|
133
|
+
tests: { type: 'array', description: 'Array of test objects: {method, path, headers?, body?, expected_status?, expected_body_contains?}', required: true }) do |tests:|
|
|
134
|
+
unless tests.is_a?(Array)
|
|
135
|
+
next { error: 'tests must be an array of test objects' }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
results = tests.map do |test|
|
|
139
|
+
test = test.is_a?(Hash) ? test : {}
|
|
140
|
+
method = (test['method'] || test[:method] || 'GET').to_s.upcase
|
|
141
|
+
path = test['path'] || test[:path]
|
|
142
|
+
headers = test['headers'] || test[:headers]
|
|
143
|
+
body = test['body'] || test[:body]
|
|
144
|
+
|
|
145
|
+
unless path
|
|
146
|
+
next { method: method, path: '(missing)', error: 'Missing required field: path' }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
http_result = perform_http_request(method, path, headers, body)
|
|
150
|
+
|
|
151
|
+
if http_result[:error]
|
|
152
|
+
next { method: method, path: path, error: http_result[:error], passed: false }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
record_tested_route(method, path, http_result[:status], http_result[:duration_ms])
|
|
156
|
+
|
|
157
|
+
# Run assertions
|
|
158
|
+
passed = true
|
|
159
|
+
failures = []
|
|
160
|
+
|
|
161
|
+
expected_status = test['expected_status'] || test[:expected_status]
|
|
162
|
+
if expected_status && http_result[:status] != expected_status.to_i
|
|
163
|
+
passed = false
|
|
164
|
+
failures << "Expected status #{expected_status}, got #{http_result[:status]}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
expected_contains = test['expected_body_contains'] || test[:expected_body_contains]
|
|
168
|
+
if expected_contains
|
|
169
|
+
body_str = http_result[:body].is_a?(String) ? http_result[:body] : JSON.generate(http_result[:body])
|
|
170
|
+
unless body_str.include?(expected_contains.to_s)
|
|
171
|
+
passed = false
|
|
172
|
+
failures << "Body does not contain: '#{expected_contains}'"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
method: method,
|
|
178
|
+
path: path,
|
|
179
|
+
status: http_result[:status],
|
|
180
|
+
duration_ms: http_result[:duration_ms],
|
|
181
|
+
passed: passed,
|
|
182
|
+
failures: failures,
|
|
183
|
+
body_preview: (http_result[:body].is_a?(String) ? http_result[:body][0..200] : http_result[:body])
|
|
184
|
+
}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
total = results.size
|
|
188
|
+
passed_count = results.count { |r| r[:passed] }
|
|
189
|
+
failed_count = total - passed_count
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
total: total,
|
|
193
|
+
passed: passed_count,
|
|
194
|
+
failed: failed_count,
|
|
195
|
+
pass_rate: total.zero? ? '0%' : format('%.0f%%', passed_count.to_f / total * 100),
|
|
196
|
+
results: results
|
|
197
|
+
}
|
|
198
|
+
rescue => e
|
|
199
|
+
{ error: e.message }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
register_tool('get_endpoint_coverage',
|
|
203
|
+
'Compare registered Sinatra/Rails routes against tested routes. Shows ' \
|
|
204
|
+
'which endpoints have been tested via the agent and which are untested') do
|
|
205
|
+
# Get all routes from the app
|
|
206
|
+
all_routes = []
|
|
207
|
+
app = DebugAgent.app
|
|
208
|
+
|
|
209
|
+
if app
|
|
210
|
+
app_class = app.is_a?(Class) ? app : app.class
|
|
211
|
+
|
|
212
|
+
if app_class.respond_to?(:routes)
|
|
213
|
+
app_class.routes.each do |method, route_list|
|
|
214
|
+
route_list.each do |route|
|
|
215
|
+
pattern = route[0]
|
|
216
|
+
pattern_str = case pattern
|
|
217
|
+
when Regexp then pattern.source
|
|
218
|
+
else pattern.to_s
|
|
219
|
+
end
|
|
220
|
+
all_routes << { method: method.to_s.upcase, pattern: pattern_str }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
if all_routes.empty? && defined?(Sinatra) && defined?(Sinatra::Base)
|
|
227
|
+
Sinatra::Base.routes.each do |method, route_list|
|
|
228
|
+
route_list.each do |route|
|
|
229
|
+
pattern = route[0]
|
|
230
|
+
pattern_str = case pattern
|
|
231
|
+
when Regexp then pattern.source
|
|
232
|
+
else pattern.to_s
|
|
233
|
+
end
|
|
234
|
+
all_routes << { method: method.to_s.upcase, pattern: pattern_str }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
tested = tested_routes_lock.synchronize { @tested_routes.dup }
|
|
240
|
+
tested_routes_set = tested.map { |t| "#{t[:method]} #{t[:path]}" }.to_set rescue tested.map { |t| "#{t[:method]} #{t[:path]}" }
|
|
241
|
+
|
|
242
|
+
# Match tested routes against app routes
|
|
243
|
+
covered = []
|
|
244
|
+
uncovered = []
|
|
245
|
+
|
|
246
|
+
all_routes.each do |route|
|
|
247
|
+
pattern = route[:pattern]
|
|
248
|
+
# Simplify regex patterns for matching (e.g. \A\/api\/orders\/(?<id>[^\/?]+) -> /api/orders)
|
|
249
|
+
base_pattern = pattern
|
|
250
|
+
.gsub(/\A\^?\\A?/, '')
|
|
251
|
+
.gsub(/\$?\\z?\z/, '')
|
|
252
|
+
.gsub(/\(\?<\w+>[^\)]+\)/, ':param')
|
|
253
|
+
.gsub(/\(\?:[^\)]+\)/, ':param')
|
|
254
|
+
.gsub(/\+|\*/, '')
|
|
255
|
+
.gsub(/\\\//, '/')
|
|
256
|
+
|
|
257
|
+
was_tested = tested.any? do |t|
|
|
258
|
+
t[:method] == route[:method] && (
|
|
259
|
+
t[:path] == pattern ||
|
|
260
|
+
t[:path].start_with?(base_pattern.gsub(/:param.*/, ''))
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
if was_tested
|
|
265
|
+
covered << route
|
|
266
|
+
else
|
|
267
|
+
uncovered << route
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
{
|
|
272
|
+
total_routes: all_routes.size,
|
|
273
|
+
tested_routes: tested_routes_set.size,
|
|
274
|
+
covered: covered.size,
|
|
275
|
+
uncovered: uncovered.size,
|
|
276
|
+
coverage_rate: all_routes.empty? ? '0%' : format('%.0f%%', covered.size.to_f / all_routes.size * 100),
|
|
277
|
+
covered_routes: covered,
|
|
278
|
+
uncovered_routes: uncovered,
|
|
279
|
+
recent_tests: tested.last(50)
|
|
280
|
+
}
|
|
281
|
+
rescue => e
|
|
282
|
+
{ error: e.message }
|
|
283
|
+
end
|
|
284
|
+
end
|