probatio_diabolica 0.2.0 → 0.3.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: 368faf074dc354becfef975422fc3199a1e83293a30bad935ef17696a9b879c9
4
- data.tar.gz: 66b84421f306926f425cdc6d9e5cc4a1ff3b8ed1cd4f6df13e86bd7313796c5d
3
+ metadata.gz: 891d8275e2ccd96dea0d74c456a55693ab667fd556fedf9b5d1fe9470b0949cb
4
+ data.tar.gz: d53faf24d75c1f112e5cb064048372d7ac7c5391f50ed3e546a5f7990cb7d02f
5
5
  SHA512:
6
- metadata.gz: f9cce79af416cc2b0e216820dd3f07f2d0ba46552db2e2598d9ab2ab83f1492757044717a383256db7b6d14168a3da4d11a0cf89949f6ec1eebe4bf55f366f2c
7
- data.tar.gz: 03e7a8a23b02b473e8bac35c4b4021e729b4e435408bdc69a6143162eebdad8172ec5bb07b83d4471b87464c5f42b1346a112d0e1d7f3af061f916838ea13ea8
6
+ metadata.gz: faafc8c84e60b7058d57ceb6d457502822724aea22325e3e0ccd4efb3e3765903f21f8525f0e4c9937bea8e69901d39e543796a776b5a1305922492955ce373e
7
+ data.tar.gz: 4d9f2a94aee2e50112544aa6c0be783ee79f95ee0b4dd7ef95731590b45f335a4e2e61044a99bfa4d2b6125767c9f9b6f91ba2b7e8b70e275ab792b62b138be3
data/README.md CHANGED
@@ -21,7 +21,7 @@ Tests are evaluated with `instance_eval` (not through RSpec).
21
21
 
22
22
  ## Installation
23
23
 
24
- ### From the gem
24
+ ### In the Gemfile
25
25
 
26
26
  ```ruby
27
27
  gem 'probatio_diabolica'
@@ -33,63 +33,6 @@ Then:
33
33
  bundle install
34
34
  ```
35
35
 
36
- In Ruby code, you can load it with:
37
-
38
- ```ruby
39
- require "probatio_diabolica"
40
- ```
41
-
42
- ### From this repository (local development)
43
-
44
- ```bash
45
- bundle install
46
- bundle exec prd examples/basics_spec.rb
47
- ```
48
-
49
- ### Build and install as a gem
50
-
51
- ```bash
52
- # build the package
53
- gem build probatio_diabolica.gemspec
54
-
55
- # install locally from the built gem
56
- gem install ./probatio_diabolica-*.gem
57
- ```
58
-
59
- ## Release workflow
60
-
61
- Use the release helper to bump version, refresh `Gemfile.lock`, create commit/tag, and push:
62
-
63
- ```bash
64
- # explicit version
65
- bin/release --version 0.2.0
66
-
67
- # or env-style
68
- VERSION=0.2.0 bin/release
69
-
70
- # semantic bump from current version
71
- bin/release --bump patch
72
- ```
73
-
74
- Useful options:
75
-
76
- - `--dry-run` preview all actions without modifying files/git
77
- - `--no-push` create commit/tag locally only
78
- - `--skip-tests` skip `bundle exec ruby bin/prd spec --mode synthetic`
79
- - `--allow-dirty` bypass clean-working-tree guard
80
-
81
- After installation, you can run:
82
-
83
- ```bash
84
- prd examples/basics_spec.rb
85
- ```
86
-
87
- If `prd` is not found, add your gem bin directory to `PATH`:
88
-
89
- ```bash
90
- export PATH="$(ruby -e 'print Gem.user_dir')/bin:$PATH"
91
- ```
92
-
93
36
  ## Configuration LLM
94
37
 
95
38
  The runtime automatically loads `prd_helper.rb` if present (or a file passed with `-c`).
@@ -119,6 +62,29 @@ From source checkout (without gem install), this is always valid:
119
62
  bundle exec ruby bin/prd <file_or_directory> [options]
120
63
  ```
121
64
 
65
+ ## MCP server (`run_specs`)
66
+
67
+ A minimal MCP server is available through:
68
+
69
+ ```bash
70
+ bundle exec ruby bin/prd_mcp
71
+ ```
72
+
73
+ It exposes one tool: `run_specs`.
74
+
75
+ Input:
76
+ - `path` (required): file or directory containing specs
77
+ - `config` (optional): same as `-c`
78
+ - `out` (optional): same as `-o`
79
+ - `formatters` (optional): array of `simple|html|json|pdf` (default: `["simple"]`)
80
+ - `mode` (optional): `verbose|synthetic` (default: `synthetic`)
81
+
82
+ Output (`structuredContent`):
83
+ - `ok`, `exit_code`
84
+ - `summary` (`passed`, `failed`, `pending`)
85
+ - `artifacts` (`base_out`, `reports`, `annex_dir`)
86
+ - `logs` (`stdout`, `stderr`)
87
+
122
88
  Options:
123
89
 
124
90
  - `-c, --config FILE` Ruby config file to require
@@ -151,33 +117,36 @@ Examples:
151
117
 
152
118
  ```bash
153
119
  # single file
154
- bundle exec ruby bin/prd examples/basics_spec.rb
120
+ prd examples/basics_spec.rb
155
121
 
156
122
  # all *_spec.rb files in a directory
157
- bundle exec ruby bin/prd examples
123
+ prd examples
158
124
 
159
125
  # HTML report in an existing directory (creates ./tmp/report.html)
160
- bundle exec ruby bin/prd examples/image_spec.rb -t html -o ./tmp/
126
+ prd examples/image_spec.rb -t html -o ./tmp/
161
127
 
162
128
  # multiple reports from one run with shared base name
163
- bundle exec ruby bin/prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
129
+ prd examples/basics_spec.rb -t html,json,pdf -o ./tmp/my_report
164
130
 
165
131
  # compact synthetic output on console
166
- bundle exec ruby bin/prd examples/basics_spec.rb --mode synthetic
132
+ prd examples/basics_spec.rb --mode synthetic
167
133
  ```
168
134
 
169
135
  ## Available DSL
170
136
 
137
+ It is inspired by RSpec but with a custom runtime and additional features.
138
+
171
139
  ### Structure
172
140
 
173
141
  ```ruby
174
142
  describe 'My domain' do
175
143
  context 'my context' do
176
- let(:value) { 5 }
177
- subject { 'hello' }
144
+ let(:two) { 2 }
145
+ let(:three) { 3 }
146
+ subject { two + three }
178
147
 
179
148
  it 'runs an assertion' do
180
- expect(value).to eq(5)
149
+ expect.to eq(5)
181
150
  end
182
151
 
183
152
  pending 'test to implement later'
@@ -231,6 +200,20 @@ end
231
200
  ### Source code helper (Prism)
232
201
 
233
202
  `source_code(...)` uses the `prism` gem to parse Ruby source and extract class/method code.
203
+ It returns a `PrD::Code` object:
204
+
205
+ - `source` (`String`)
206
+ - `language` (`String`, default: `ruby`)
207
+
208
+ Example:
209
+
210
+ ```ruby
211
+ let(:code) { source_code(PrD::Matchers::AllMatcher) }
212
+
213
+ it 'uses raw source text' do
214
+ expect(code.source).to(includes('class AllMatcher'))
215
+ end
216
+ ```
234
217
 
235
218
  Prerequisites:
236
219
 
@@ -273,13 +256,16 @@ When you define a `subject`, each formatter tries to render it in the most usefu
273
256
  - for files, prints a textual representation (for example path, file preview for `.txt`)
274
257
  - `HtmlFormatter`:
275
258
  - renders text values directly
259
+ - for `PrD::Code`, renders syntax-highlighted code blocks (Rouge)
276
260
  - for image files (`.png`, `.jpg`, `.jpeg`), embeds the image in the report
277
261
  - for PDF subjects (`File` `.pdf` or `PDF::Reader`), embeds the PDF with a `data:application/pdf;base64,...` URI
278
262
  - `PdfFormatter`:
279
263
  - renders text values as report lines
264
+ - for `PrD::Code`, renders language + code block (plain, without syntax colors)
280
265
  - for image files (`.png`, `.jpg`, `.jpeg`), inserts the image directly in the PDF report
281
266
  - `JsonFormatter`:
282
267
  - keeps a structured representation for machine processing
268
+ - for `PrD::Code`, emits a structured payload (`type: "code"`, `language`, `source`)
283
269
  - `File` values (images, PDFs, text files, etc.) are embedded as base64 payloads
284
270
  - `PDF::Reader` values are also embedded as base64 (`application/pdf`)
285
271
 
data/bin/prd_mcp ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'pr_d'
5
+ rescue LoadError
6
+ require_relative '../lib/pr_d'
7
+ end
8
+
9
+ PrD::Mcp::Server.new.run
data/lib/pr_d/code.rb ADDED
@@ -0,0 +1,18 @@
1
+ module PrD
2
+ class Code
3
+ attr_reader :source, :language
4
+
5
+ def initialize(source:, language: 'ruby')
6
+ @source = source.to_s
7
+ @language = language.to_s
8
+ end
9
+
10
+ def to_s
11
+ @source
12
+ end
13
+
14
+ def include?(value)
15
+ @source.include?(value)
16
+ end
17
+ end
18
+ end
@@ -100,6 +100,10 @@ module PrD
100
100
 
101
101
  value
102
102
  end
103
+
104
+ def code_object?(value)
105
+ defined?(PrD::Code) && value.is_a?(PrD::Code)
106
+ end
103
107
  end
104
108
  end
105
109
  end
@@ -1,5 +1,6 @@
1
1
  require 'cgi'
2
2
  require 'base64'
3
+ require 'rouge'
3
4
 
4
5
  module PrD
5
6
  module Formatters
@@ -9,6 +10,7 @@ module PrD
9
10
  @content = +''
10
11
  @index_entries = []
11
12
  @anchor_counters = Hash.new(0)
13
+ @rouge_formatter = Rouge::Formatters::HTMLLegacy.new(css_class: 'highlight')
12
14
  end
13
15
 
14
16
  def context(message)
@@ -51,20 +53,13 @@ module PrD
51
53
  end
52
54
 
53
55
  def let(value)
56
+ return if synthetic?
57
+ render_value_block('Let', value)
54
58
  end
55
59
 
56
60
  def subject(subject)
57
61
  return if synthetic?
58
- @content << '<div class="subject-block">'
59
- @content << "<p class=\"line\"><strong>Subject:</strong> #{escape(serialize(subject).to_s)}</p>"
60
- if image_file?(subject)
61
- @content << "<img src=\"#{image_data_uri(subject.path)}\" alt=\"Subject image\" class=\"subject-image\" />"
62
- elsif pdf_file?(subject)
63
- @content << "<embed src=\"#{pdf_data_uri(subject.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
64
- elsif pdf_reader?(subject)
65
- @content << "<embed src=\"#{pdf_reader_data_uri(subject)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
66
- end
67
- @content << '</div>'
62
+ render_value_block('Subject', subject)
68
63
  end
69
64
 
70
65
  def pending(description = nil)
@@ -85,7 +80,7 @@ module PrD
85
80
 
86
81
  def expect(expectation)
87
82
  return if synthetic?
88
- @content << "<p class=\"line\"><strong>Expect:</strong> #{escape(serialize(expectation).to_s)}</p>"
83
+ render_labeled_value('Expect', expectation)
89
84
  end
90
85
 
91
86
  def to
@@ -102,15 +97,15 @@ module PrD
102
97
  return if synthetic?
103
98
  case matcher
104
99
  when Matchers::EqMatcher
105
- @content << "<p class=\"line\"><strong>Matcher:</strong> Be equal to #{escape(serialize(matcher.expected).to_s)}</p>"
100
+ render_matcher_value('Be equal to', matcher.expected)
106
101
  when Matchers::BeMatcher
107
- @content << "<p class=\"line\"><strong>Matcher:</strong> Be the same object as #{escape(serialize(matcher.expected).to_s)}</p>"
102
+ render_matcher_value('Be the same object as', matcher.expected)
108
103
  when Matchers::IncludesMatcher
109
- @content << "<p class=\"line\"><strong>Matcher:</strong> Include #{escape(serialize(matcher.expected).to_s)}</p>"
104
+ render_matcher_value('Include', matcher.expected)
110
105
  when Matchers::HaveMatcher
111
- @content << "<p class=\"line\"><strong>Matcher:</strong> Have #{escape(serialize(matcher.expected).to_s)}</p>"
106
+ render_matcher_value('Have', matcher.expected)
112
107
  when Matchers::LlmMatcher
113
- @content << "<p class=\"line\"><strong>Matcher:</strong> Satisfy condition #{escape(serialize(matcher.expected).to_s)}</p>"
108
+ render_matcher_value('Satisfy condition', matcher.expected)
114
109
  when Matchers::AllMatcher
115
110
  if sources
116
111
  code_line = matcher.expected.source_location.last.to_i
@@ -156,6 +151,7 @@ module PrD
156
151
  --muted: #6b7280;
157
152
  --line: #e5e7eb;
158
153
  --accent: #0f766e;
154
+ --sidebar-width: 320px;
159
155
  --pass-bg: #ecfdf5;
160
156
  --pass-fg: #166534;
161
157
  --fail-bg: #fef2f2;
@@ -182,12 +178,51 @@ module PrD
182
178
  border-radius: 18px;
183
179
  }
184
180
 
181
+ body.has-index main.container {
182
+ width: min(960px, calc(100% - var(--sidebar-width) - 4rem));
183
+ margin: 1rem 1rem 2rem calc(var(--sidebar-width) + 2rem);
184
+ }
185
+
186
+ body.has-index.index-collapsed main.container {
187
+ width: min(960px, calc(100% - 2rem));
188
+ margin: 1rem auto 2rem;
189
+ }
190
+
191
+ .index-toggle {
192
+ position: fixed;
193
+ top: 0.9rem;
194
+ left: 0.9rem;
195
+ z-index: 1100;
196
+ border: 1px solid #d1d5db;
197
+ border-radius: 999px;
198
+ background: var(--paper);
199
+ color: #0f172a;
200
+ padding: 0.45rem 0.8rem;
201
+ font-size: 0.9rem;
202
+ font-weight: 600;
203
+ cursor: pointer;
204
+ box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12);
205
+ }
206
+
185
207
  .report-index {
208
+ position: fixed;
209
+ top: 3.2rem;
210
+ left: 1rem;
211
+ bottom: 1rem;
212
+ z-index: 1000;
213
+ width: var(--sidebar-width);
186
214
  background: var(--paper);
187
215
  border: 1px solid var(--line);
188
216
  border-radius: 14px;
189
217
  padding: 0.9rem 1rem;
190
- margin-bottom: 1rem;
218
+ overflow-y: auto;
219
+ transition: transform 0.18s ease, opacity 0.18s ease;
220
+ }
221
+
222
+ body.has-index.index-collapsed .report-index {
223
+ transform: translateX(calc(-1 * (var(--sidebar-width) + 1rem)));
224
+ opacity: 0;
225
+ pointer-events: none;
191
226
  }
192
227
 
193
228
  .index-title {
@@ -207,12 +242,16 @@ module PrD
207
242
  padding-left: calc(var(--index-level, 0) * 1rem);
208
243
  }
209
244
 
210
- .index-item a {
245
+ .index-link {
246
+ display: block;
247
+ white-space: nowrap;
248
+ overflow: hidden;
249
+ text-overflow: ellipsis;
211
250
  color: var(--accent);
212
251
  text-decoration: none;
213
252
  }
214
253
 
215
- .index-item a:hover {
254
+ .index-link:hover {
216
255
  text-decoration: underline;
217
256
  }
218
257
 
@@ -314,6 +353,53 @@ module PrD
314
353
  .result.success { color: var(--pass-fg); background: var(--pass-bg); }
315
354
  .result.failure { color: var(--fail-fg); background: var(--fail-bg); }
316
355
  .muted { color: var(--muted); }
356
+
357
+ .code-language {
358
+ color: var(--muted);
359
+ font-size: 0.85rem;
360
+ margin-bottom: 0.35rem;
361
+ text-transform: uppercase;
362
+ letter-spacing: 0.05em;
363
+ }
364
+
365
+ .code-block {
366
+ margin: 0.45rem 0 0.7rem;
367
+ }
368
+
369
+ .highlight {
370
+ margin: 0;
371
+ border: 1px solid var(--line);
372
+ border-radius: 10px;
373
+ overflow-x: auto;
374
+ }
375
+
376
+ .highlight pre {
377
+ margin: 0;
378
+ padding: 0.8rem;
379
+ line-height: 1.35;
380
+ }
381
+
382
+ @media (max-width: 960px) {
383
+ :root { --sidebar-width: min(82vw, 320px); }
384
+
385
+ body.has-index main.container,
386
+ body.has-index.index-collapsed main.container {
387
+ width: calc(100% - 1rem);
388
+ margin: 4rem 0.5rem 1rem 0.5rem;
389
+ }
390
+
391
+ .report-index {
392
+ top: 3.5rem;
393
+ left: 0.5rem;
394
+ bottom: 0.5rem;
395
+ }
396
+
397
+ body.has-index.index-collapsed .report-index {
398
+ transform: translateX(calc(-1 * (var(--sidebar-width) + 0.6rem)));
399
+ }
400
+ }
401
+
402
+ #{rouge_theme_css}
317
403
  </style>
318
404
  </head>
319
405
  <body>
@@ -325,28 +411,52 @@ module PrD
325
411
  return '' if @index_entries.empty?
326
412
 
327
413
  index_items = @index_entries.map do |entry|
328
- "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a href=\"##{entry[:anchor_id]}\">#{escape(index_label(entry))}</a></li>"
414
+ label = escape(index_label(entry))
415
+ "<li class=\"index-item\" style=\"--index-level: #{entry[:level]};\"><a class=\"index-link\" href=\"##{entry[:anchor_id]}\" title=\"#{label}\">#{label}</a></li>"
329
416
  end.join
330
417
 
331
418
  <<~HTML
332
- <nav class="report-index" aria-label="Report index">
419
+ <button type="button" class="index-toggle" aria-expanded="false" aria-controls="report-index">Show index</button>
420
+ <nav id="report-index" class="report-index" aria-label="Report index">
333
421
  <h2 class="index-title">Index</h2>
334
422
  <ul class="index-list">
335
423
  #{index_items}
336
424
  </ul>
337
425
  </nav>
426
+ <script>
427
+ (function() {
428
+ var body = document.body;
429
+ var nav = document.getElementById('report-index');
430
+ var toggle = document.querySelector('.index-toggle');
431
+ if (!body || !nav || !toggle) return;
432
+
433
+ body.classList.add('has-index');
434
+
435
+ var syncToggleLabel = function() {
436
+ var isCollapsed = body.classList.contains('index-collapsed');
437
+ toggle.setAttribute('aria-expanded', String(!isCollapsed));
438
+ toggle.textContent = isCollapsed ? 'Show index' : 'Hide index';
439
+ };
440
+
441
+ toggle.addEventListener('click', function() {
442
+ body.classList.toggle('index-collapsed');
443
+ syncToggleLabel();
444
+ });
445
+
446
+ syncToggleLabel();
447
+ })();
448
+ </script>
338
449
  HTML
339
450
  end
340
451
 
341
452
  def index_label(entry)
342
- prefix =
453
+ marker =
343
454
  case entry[:type]
344
- when :context then 'Context'
345
- when :pending then 'Pending'
346
- else 'Test'
455
+ when :context then '+'
456
+ else '-'
347
457
  end
348
458
 
349
- "#{prefix}: #{entry[:label]}"
459
+ "#{marker} #{entry[:label]}"
350
460
  end
351
461
 
352
462
  def add_index_entry(type:, label:, level:, anchor_id:)
@@ -363,6 +473,70 @@ module PrD
363
473
  "#{prefix}-#{@anchor_counters[prefix]}"
364
474
  end
365
475
 
476
+ def render_labeled_value(label, value)
477
+ if code_object?(value)
478
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
479
+ @content << render_code_block(value)
480
+ else
481
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
482
+ end
483
+ end
484
+
485
+ def render_matcher_value(label, value)
486
+ if code_object?(value)
487
+ @content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} (#{escape(value.language)})</p>"
488
+ @content << render_code_block(value)
489
+ else
490
+ @content << "<p class=\"line\"><strong>Matcher:</strong> #{escape(label)} #{escape(serialize(value).to_s)}</p>"
491
+ end
492
+ end
493
+
494
+ def render_value_block(label, value)
495
+ @content << '<div class="subject-block">'
496
+ if code_object?(value)
497
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong></p>"
498
+ @content << render_code_block(value)
499
+ else
500
+ @content << "<p class=\"line\"><strong>#{escape(label)}:</strong> #{escape(serialize(value).to_s)}</p>"
501
+ end
502
+
503
+ if image_file?(value)
504
+ @content << "<img src=\"#{image_data_uri(value.path)}\" alt=\"#{escape(label)} image\" class=\"subject-image\" />"
505
+ elsif pdf_file?(value)
506
+ @content << "<embed src=\"#{pdf_data_uri(value.path)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
507
+ elsif pdf_reader?(value)
508
+ @content << "<embed src=\"#{pdf_reader_data_uri(value)}\" type=\"application/pdf\" class=\"subject-pdf\" />"
509
+ end
510
+ @content << '</div>'
511
+ end
512
+
513
+ def render_code_block(code)
514
+ source = normalize_text(code.source)
515
+ language = normalize_text(code.language)
516
+ highlighted = highlight_code(source, language)
517
+ <<~HTML
518
+ <div class="code-block">
519
+ <div class="code-language">#{escape(language)}</div>
520
+ #{highlighted}
521
+ </div>
522
+ HTML
523
+ end
524
+
525
+ def highlight_code(source, language)
526
+ lexer = rouge_lexer_for(source, language)
527
+ @rouge_formatter.format(lexer.lex(source))
528
+ end
529
+
530
+ def rouge_lexer_for(source, language)
531
+ Rouge::Lexer.find_fancy(language, source) || Rouge::Lexers::PlainText
532
+ rescue StandardError
533
+ Rouge::Lexers::PlainText
534
+ end
535
+
536
+ def rouge_theme_css
537
+ Rouge::Themes::Github.render(scope: '.highlight')
538
+ end
539
+
366
540
  def escape(message)
367
541
  CGI.escape_html(normalize_text(message))
368
542
  end
@@ -117,6 +117,10 @@ module PrD
117
117
  serializer = @serializers[value.class]
118
118
  return serializer.call(value) if serializer
119
119
 
120
+ if code_object?(value)
121
+ return serialize_code(value)
122
+ end
123
+
120
124
  if value.is_a?(File)
121
125
  return serialize_file(value)
122
126
  end
@@ -136,6 +140,14 @@ module PrD
136
140
  value
137
141
  end
138
142
 
143
+ def serialize_code(code)
144
+ {
145
+ type: 'code',
146
+ language: code.language,
147
+ source: code.source
148
+ }
149
+ end
150
+
139
151
  def serialize_file(file)
140
152
  file.rewind if file.respond_to?(:rewind)
141
153
  content = file.read
@@ -74,7 +74,10 @@ module PrD
74
74
  def subject(subject)
75
75
  return if synthetic?
76
76
  add_event(:subject, message: 'Subject', level: @level)
77
- if image_file?(subject)
77
+ if code_object?(subject)
78
+ add_event(:code_header, message: "Language: #{subject.language}", level: @level + 1)
79
+ add_event(:code_block, message: subject.source, level: @level + 1)
80
+ elsif image_file?(subject)
78
81
  add_event(:detail, message: serialize(subject).to_s, level: @level + 1)
79
82
  add_event(:subject_image, message: subject.path, level: @level + 1)
80
83
  else
@@ -93,7 +96,12 @@ module PrD
93
96
 
94
97
  def expect(expectation)
95
98
  return if synthetic?
96
- add_event(:detail, message: "Expect: #{serialize(expectation)}", level: @level + 1)
99
+ if code_object?(expectation)
100
+ add_event(:code_header, message: "Expect (#{expectation.language})", level: @level + 1)
101
+ add_event(:code_block, message: expectation.source, level: @level + 1)
102
+ else
103
+ add_event(:detail, message: "Expect: #{serialize(expectation)}", level: @level + 1)
104
+ end
97
105
  end
98
106
 
99
107
  def to
@@ -110,15 +118,15 @@ module PrD
110
118
  return if synthetic?
111
119
  case matcher
112
120
  when Matchers::EqMatcher
113
- add_event(:matcher, message: "Be equal to: #{serialize(matcher.expected)}", level: @level + 2)
121
+ add_matcher_value_event('Be equal to', matcher.expected)
114
122
  when Matchers::BeMatcher
115
- add_event(:matcher, message: "Be the same object as: #{serialize(matcher.expected)}", level: @level + 2)
123
+ add_matcher_value_event('Be the same object as', matcher.expected)
116
124
  when Matchers::IncludesMatcher
117
- add_event(:matcher, message: "Include: #{serialize(matcher.expected)}", level: @level + 2)
125
+ add_matcher_value_event('Include', matcher.expected)
118
126
  when Matchers::HaveMatcher
119
- add_event(:matcher, message: "Have: #{serialize(matcher.expected)}", level: @level + 2)
127
+ add_matcher_value_event('Have', matcher.expected)
120
128
  when Matchers::LlmMatcher
121
- add_event(:matcher, message: "Satisfy condition: #{serialize(matcher.expected)}", level: @level + 2)
129
+ add_matcher_value_event('Satisfy condition', matcher.expected)
122
130
  when Matchers::AllMatcher
123
131
  add_event(:matcher, message: 'all match the given condition', level: @level + 2)
124
132
  else
@@ -227,6 +235,10 @@ module PrD
227
235
  status_line(document, 'PENDING', event[:message], event[:level], COLORS[:pending])
228
236
  when :matcher
229
237
  styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:muted])
238
+ when :code_header
239
+ styled_line(document, event[:message], level: event[:level], size: 10, style: :bold, color: COLORS[:muted])
240
+ when :code_block
241
+ render_code_block(document, event[:message], level: event[:level])
230
242
  when :detail, :subject, :justification
231
243
  styled_line(document, event[:message], level: event[:level], size: 10, color: COLORS[:text])
232
244
  when :subject_image
@@ -284,6 +296,28 @@ module PrD
284
296
  document.move_down 2
285
297
  end
286
298
 
299
+ def render_code_block(document, text, level:)
300
+ document.indent(level * 14) do
301
+ document.fill_color COLORS[:muted]
302
+ document.text '--- Code Block ---', size: 9, style: :italic
303
+ document.fill_color COLORS[:text]
304
+ document.font('Courier') { document.text text, size: 9 }
305
+ document.fill_color COLORS[:muted]
306
+ document.text '--- End Block ---', size: 9, style: :italic
307
+ document.fill_color COLORS[:text]
308
+ end
309
+ document.move_down 3
310
+ end
311
+
312
+ def add_matcher_value_event(label, value)
313
+ if code_object?(value)
314
+ add_event(:matcher, message: "#{label} (#{value.language})", level: @level + 2)
315
+ add_event(:code_block, message: value.source, level: @level + 2)
316
+ else
317
+ add_event(:matcher, message: "#{label}: #{serialize(value)}", level: @level + 2)
318
+ end
319
+ end
320
+
287
321
  def index_label(entry)
288
322
  prefix =
289
323
  case entry[:type]
@@ -47,7 +47,12 @@ module PrD
47
47
  def subject(subject)
48
48
  return if synthetic?
49
49
  title('Subject')
50
- output(subject, :white, indent: 1)
50
+ if code_object?(subject)
51
+ output("Code (#{subject.language}):", :white, indent: 1)
52
+ output(subject.source, :white, indent: 2)
53
+ else
54
+ output(subject, :white, indent: 1)
55
+ end
51
56
  end
52
57
 
53
58
  def pending(description = nil)
@@ -61,7 +66,12 @@ module PrD
61
66
 
62
67
  def expect(expectation)
63
68
  return if synthetic?
64
- output("Expect: #{expectation}", :white, indent: 1)
69
+ if code_object?(expectation)
70
+ output("Expect (#{expectation.language}):", :white, indent: 1)
71
+ output(expectation.source, :white, indent: 2)
72
+ else
73
+ output("Expect: #{expectation}", :white, indent: 1)
74
+ end
65
75
  end
66
76
 
67
77
  def to
@@ -81,15 +91,15 @@ module PrD
81
91
  return if synthetic?
82
92
  case matcher
83
93
  when Matchers::EqMatcher
84
- output("Be equal to: #{matcher.expected}", :white, indent: 2)
94
+ output_matcher_value('Be equal to', matcher.expected)
85
95
  when Matchers::BeMatcher
86
- output("Be the same object as: #{matcher.expected}", :white, indent: 2)
96
+ output_matcher_value('Be the same object as', matcher.expected)
87
97
  when Matchers::IncludesMatcher
88
- output("Include: #{matcher.expected}", :white, indent: 2)
98
+ output_matcher_value('Include', matcher.expected)
89
99
  when Matchers::HaveMatcher
90
- output("Have: #{matcher.expected}", :white, indent: 2)
100
+ output_matcher_value('Have', matcher.expected)
91
101
  when Matchers::LlmMatcher
92
- output("Satisfy condition: #{matcher.expected}", :white, indent: 2)
102
+ output_matcher_value('Satisfy condition', matcher.expected)
93
103
  when Matchers::AllMatcher
94
104
  if sources
95
105
  code_line = matcher.expected.source_location.last.to_i
@@ -117,6 +127,9 @@ module PrD
117
127
  @io.puts "#{INDENT * indent}#{message}"
118
128
  when Array
119
129
  message.each { |line| output(line, color, figure: figure, indent: indent) }
130
+ when PrD::Code
131
+ output("Code (#{message.language}):", color, indent: indent)
132
+ output(message.source, color, indent: indent + 1)
120
133
  when String
121
134
  if message.include?("\n")
122
135
  @io.puts "#{COLOR_MAPPING[color]}#{INDENT * indent}--- Code Block ---#{COLOR_MAPPING[:default]}"
@@ -152,6 +165,15 @@ module PrD
152
165
  output(message, :yellow)
153
166
  end
154
167
 
168
+ def output_matcher_value(label, value)
169
+ if code_object?(value)
170
+ output("#{label} (#{value.language}):", :white, indent: 2)
171
+ output(value.source, :white, indent: 3)
172
+ else
173
+ output("#{label}: #{value}", :white, indent: 2)
174
+ end
175
+ end
176
+
155
177
  def indented_message(message, indent_incr: 0)
156
178
  "#{INDENT * (@level + indent_incr)}#{message}"
157
179
  end
@@ -23,12 +23,7 @@ module PrD
23
23
  text_node = browser.at_css(css)
24
24
  raise ArgumentError, "CSS selector not found: #{css}" unless text_node
25
25
 
26
- text_id = Digest::SHA256.hexdigest("#{at}-#{css}")
27
- file_name = File.join(chrome_annex_dir, "text-#{text_id}.txt")
28
- File.open(file_name, 'w') do |file|
29
- file.write(text_node.text.scan(/.{1,100}/m).join("\n"))
30
- end
31
- File.open(file_name, 'rb')
26
+ PrD::Code.new(source: text_node.text, language: 'text')
32
27
  end
33
28
 
34
29
  def network(at:, warmup_time: 2)
@@ -54,7 +49,8 @@ module PrD
54
49
  def html(at:, warmup_time: 2)
55
50
  browser = prepare_browser(at:, warmup_time:)
56
51
  yield browser if block_given?
57
- browser.body
52
+
53
+ PrD::Code.new(source: browser.body, language: 'html')
58
54
  end
59
55
 
60
56
  def close_chrome_browser
@@ -10,14 +10,14 @@ module PrD
10
10
 
11
11
  code = File.read(file)
12
12
  tree = Prism.parse(code)
13
- extract_class_from_node(tree.value, class_or_method.to_s, code)
13
+ extract_code_object(extract_class_from_node(tree.value, class_or_method.to_s, code))
14
14
  else
15
15
  file, line = class_or_method.source_location
16
16
  return nil unless file && line
17
17
 
18
18
  code = File.read(file)
19
19
  tree = Prism.parse(code)
20
- extract_method_from_node(tree.value, class_or_method.name, code)
20
+ extract_code_object(extract_method_from_node(tree.value, class_or_method.name, code))
21
21
  end
22
22
  end
23
23
 
@@ -61,6 +61,12 @@ module PrD
61
61
  rescue LoadError => e
62
62
  raise LoadError, "Source code helpers require the 'prism' gem. Install it with `gem install prism` or add `gem 'prism'` to your Gemfile. (#{e.message})"
63
63
  end
64
+
65
+ def extract_code_object(source)
66
+ return nil if source.nil?
67
+
68
+ PrD::Code.new(source:, language: 'ruby')
69
+ end
64
70
  end
65
71
  end
66
72
  end
@@ -5,6 +5,8 @@ module PrD
5
5
  def matches?(actual)
6
6
  if actual.is_a?(String) || actual.is_a?(Array)
7
7
  PrD::Runtime::TestResult.new(comment: nil, pass: actual.include?(@expected))
8
+ elsif defined?(PrD::Code) && actual.is_a?(PrD::Code)
9
+ PrD::Runtime::TestResult.new(comment: nil, pass: actual.source.include?(@expected))
8
10
  elsif actual.is_a?(File)
9
11
  content = actual.read
10
12
  actual.rewind
@@ -24,6 +24,8 @@ module PrD
24
24
  def matches?(actual)
25
25
  if actual.is_a?(String)
26
26
  return build_runtime_result(text(@expected, actual))
27
+ elsif defined?(PrD::Code) && actual.is_a?(PrD::Code)
28
+ return build_runtime_result(text(@expected, actual.source))
27
29
  elsif actual.is_a?(File)
28
30
  if actual.path.end_with?('.png', '.jpg', '.jpeg')
29
31
  return build_runtime_result(image(@expected, actual))
@@ -0,0 +1,248 @@
1
+ require 'json'
2
+ require 'open3'
3
+
4
+ module PrD
5
+ module Mcp
6
+ class RunSpecsTool
7
+ SUPPORTED_FORMATTERS = %w[simple html json pdf].freeze
8
+ SUPPORTED_MODES = %w[verbose synthetic].freeze
9
+ FORMATTER_EXTENSIONS = {
10
+ 'simple' => '.txt',
11
+ 'html' => '.html',
12
+ 'json' => '.json',
13
+ 'pdf' => '.pdf'
14
+ }.freeze
15
+ DEFAULT_REPORT_BASENAME = 'report'.freeze
16
+
17
+ def initialize(command_runner: Open3, pwd: Dir.pwd)
18
+ @command_runner = command_runner
19
+ @pwd = pwd
20
+ end
21
+
22
+ def call(arguments)
23
+ args = normalize_and_validate_arguments(arguments)
24
+ command = build_command(args)
25
+ stdout, stderr, status = @command_runner.capture3(*command, chdir: @pwd)
26
+
27
+ base_out = args[:out] ? output_base_path(args[:out]) : nil
28
+ parsed_json = parse_json_summary(args:, stdout:, base_out:)
29
+
30
+ {
31
+ ok: true,
32
+ exit_code: status.exitstatus,
33
+ summary: build_summary(args:, stdout:, parsed_json:),
34
+ artifacts: build_artifacts(args:, base_out:),
35
+ logs: {
36
+ stdout: stdout,
37
+ stderr: stderr
38
+ }
39
+ }
40
+ rescue StandardError => e
41
+ raise e if e.is_a?(ArgumentError)
42
+
43
+ {
44
+ ok: false,
45
+ exit_code: nil,
46
+ summary: { passed: nil, failed: nil, pending: nil },
47
+ artifacts: { base_out: nil, reports: [], annex_dir: nil },
48
+ logs: { stdout: '', stderr: "#{e.class}: #{e.message}" }
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def normalize_and_validate_arguments(raw_args)
55
+ raise ArgumentError, 'run_specs requires an arguments object.' unless raw_args.is_a?(Hash)
56
+
57
+ path = raw_args['path'] || raw_args[:path]
58
+ raise ArgumentError, 'run_specs requires `path` (string).' unless path.is_a?(String) && !path.strip.empty?
59
+
60
+ absolute_path = File.expand_path(path, @pwd)
61
+ raise ArgumentError, "Path not found: #{path}" unless File.exist?(absolute_path)
62
+
63
+ formatters = normalize_formatters(raw_args['formatters'] || raw_args[:formatters])
64
+ mode = normalize_mode(raw_args['mode'] || raw_args[:mode])
65
+ out = normalize_optional_string(raw_args['out'] || raw_args[:out])
66
+ config = normalize_optional_string(raw_args['config'] || raw_args[:config])
67
+
68
+ if (formatters.length > 1 || formatters.include?('pdf')) && out.nil?
69
+ raise ArgumentError, 'Using multiple formatters or pdf requires `out`.'
70
+ end
71
+
72
+ {
73
+ path: absolute_path,
74
+ formatters: formatters,
75
+ mode: mode,
76
+ out: out,
77
+ config: config
78
+ }
79
+ end
80
+
81
+ def normalize_optional_string(value)
82
+ return nil if value.nil?
83
+ return value if value.is_a?(String) && !value.strip.empty?
84
+
85
+ raise ArgumentError, 'Optional arguments (`config`, `out`) must be non-empty strings when provided.'
86
+ end
87
+
88
+ def normalize_formatters(value)
89
+ return ['simple'] if value.nil?
90
+
91
+ unless value.is_a?(Array) && !value.empty?
92
+ raise ArgumentError, '`formatters` must be a non-empty array when provided.'
93
+ end
94
+
95
+ formatters = value.map do |formatter|
96
+ formatter_string = formatter.to_s.strip
97
+ raise ArgumentError, '`formatters` cannot contain empty values.' if formatter_string.empty?
98
+
99
+ formatter_string
100
+ end
101
+
102
+ unknown_formatter = formatters.find { |formatter| !SUPPORTED_FORMATTERS.include?(formatter) }
103
+ raise ArgumentError, "Unsupported formatter: #{unknown_formatter}" if unknown_formatter
104
+
105
+ formatters.uniq
106
+ end
107
+
108
+ def normalize_mode(value)
109
+ return 'synthetic' if value.nil?
110
+
111
+ mode = value.to_s
112
+ raise ArgumentError, "Unsupported mode: #{mode}" unless SUPPORTED_MODES.include?(mode)
113
+
114
+ mode
115
+ end
116
+
117
+ def build_command(args)
118
+ bin_path = File.expand_path('../../../bin/prd', __dir__)
119
+ command = ['bundle', 'exec', 'ruby', bin_path, args[:path]]
120
+ command << '--mode' << args[:mode]
121
+
122
+ args[:formatters].each do |formatter|
123
+ command << '-t' << formatter
124
+ end
125
+
126
+ if args[:config]
127
+ command << '-c' << File.expand_path(args[:config], @pwd)
128
+ end
129
+
130
+ if args[:out]
131
+ command << '-o' << File.expand_path(args[:out], @pwd)
132
+ end
133
+
134
+ command
135
+ end
136
+
137
+ def build_summary(args:, stdout:, parsed_json:)
138
+ parsed = parsed_json || parse_simple_summary(stdout)
139
+ return { passed: nil, failed: nil, pending: nil } unless parsed
140
+
141
+ pending_count = parsed[:pending]
142
+ if pending_count.nil? && args[:mode] == 'synthetic' && args[:formatters].include?('simple')
143
+ pending_count = pending_count_from_simple_output(stdout)
144
+ end
145
+
146
+ {
147
+ passed: parsed[:passed],
148
+ failed: parsed[:failed],
149
+ pending: pending_count
150
+ }
151
+ end
152
+
153
+ def parse_json_summary(args:, stdout:, base_out:)
154
+ return nil unless args[:formatters].include?('json')
155
+
156
+ json_payload = nil
157
+ if base_out
158
+ json_path = "#{base_out}#{FORMATTER_EXTENSIONS['json']}"
159
+ json_payload = File.read(json_path) if File.exist?(json_path)
160
+ elsif args[:formatters] == ['json']
161
+ json_payload = stdout
162
+ end
163
+
164
+ return nil unless json_payload
165
+
166
+ parsed = JSON.parse(json_payload)
167
+ summary = parsed['summary'] || {}
168
+
169
+ pending = nil
170
+ events = parsed['events']
171
+ if events.is_a?(Array)
172
+ pending = events.count do |event|
173
+ (event['type'] == 'test_result' && event['status'] == 'PENDING') || event['type'] == 'pending'
174
+ end
175
+ end
176
+
177
+ {
178
+ passed: summary['passed'],
179
+ failed: summary['failed'],
180
+ pending: pending
181
+ }
182
+ rescue JSON::ParserError
183
+ nil
184
+ end
185
+
186
+ def parse_simple_summary(stdout)
187
+ plain_output = strip_ansi(stdout)
188
+ matches = plain_output.scan(/(\d+)\s+passed,\s+(\d+)\s+failed/)
189
+ return nil if matches.empty?
190
+ match = matches.last
191
+
192
+ {
193
+ passed: match[0].to_i,
194
+ failed: match[1].to_i,
195
+ pending: nil
196
+ }
197
+ end
198
+
199
+ def pending_count_from_simple_output(stdout)
200
+ strip_ansi(stdout).scan(/^PENDING:\s+/).count
201
+ end
202
+
203
+ def strip_ansi(text)
204
+ text.gsub(/\e\[[0-9;]*m/, '')
205
+ end
206
+
207
+ def build_artifacts(args:, base_out:)
208
+ reports = []
209
+ if base_out
210
+ args[:formatters].each do |formatter|
211
+ report_path = "#{base_out}#{FORMATTER_EXTENSIONS.fetch(formatter)}"
212
+ reports << {
213
+ type: formatter,
214
+ path: report_path,
215
+ exists: File.exist?(report_path)
216
+ }
217
+ end
218
+ end
219
+
220
+ annex_dir = if base_out
221
+ candidate = File.join(File.dirname(base_out), 'annex')
222
+ candidate if Dir.exist?(candidate)
223
+ end
224
+
225
+ {
226
+ base_out: base_out,
227
+ reports: reports,
228
+ annex_dir: annex_dir
229
+ }
230
+ end
231
+
232
+ def output_base_path(out_path)
233
+ resolved = File.expand_path(out_path, @pwd)
234
+ if directory_like_path?(resolved)
235
+ File.join(resolved, DEFAULT_REPORT_BASENAME)
236
+ else
237
+ ext = File.extname(resolved).downcase
238
+ known_extensions = FORMATTER_EXTENSIONS.values
239
+ known_extensions.include?(ext) ? resolved[...-ext.length] : resolved
240
+ end
241
+ end
242
+
243
+ def directory_like_path?(path)
244
+ path.end_with?(File::SEPARATOR) || Dir.exist?(path)
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,178 @@
1
+ require 'json'
2
+
3
+ module PrD
4
+ module Mcp
5
+ class Server
6
+ JSONRPC_VERSION = '2.0'.freeze
7
+ MCP_PROTOCOL_VERSION = '2024-11-05'.freeze
8
+ RUN_SPECS_TOOL_NAME = 'run_specs'.freeze
9
+
10
+ def initialize(input: $stdin, output: $stdout, run_specs_tool: RunSpecsTool.new)
11
+ @input = input
12
+ @output = output
13
+ @run_specs_tool = run_specs_tool
14
+ end
15
+
16
+ def run
17
+ while (message = read_message)
18
+ response = process_message(message)
19
+ write_message(response) if response
20
+ end
21
+ end
22
+
23
+ def process_message(message)
24
+ id = message['id']
25
+ method = message['method']
26
+ params = message['params'] || {}
27
+
28
+ case method
29
+ when 'initialize'
30
+ success_response(id, initialize_result)
31
+ when 'notifications/initialized'
32
+ nil
33
+ when 'tools/list'
34
+ success_response(id, tools_list_result)
35
+ when 'tools/call'
36
+ success_response(id, handle_tool_call(params))
37
+ else
38
+ return nil unless id
39
+
40
+ error_response(id, -32601, "Method not found: #{method}")
41
+ end
42
+ rescue StandardError => e
43
+ return nil unless id
44
+
45
+ error_response(id, -32603, "Internal error: #{e.message}")
46
+ end
47
+
48
+ private
49
+
50
+ def initialize_result
51
+ {
52
+ protocolVersion: MCP_PROTOCOL_VERSION,
53
+ capabilities: {
54
+ tools: {}
55
+ },
56
+ serverInfo: {
57
+ name: 'probatio-diabolica-mcp',
58
+ version: PrD::VERSION
59
+ }
60
+ }
61
+ end
62
+
63
+ def tools_list_result
64
+ {
65
+ tools: [run_specs_definition]
66
+ }
67
+ end
68
+
69
+ def run_specs_definition
70
+ {
71
+ name: RUN_SPECS_TOOL_NAME,
72
+ description: 'Run probatio_diabolica specs from a file or directory path.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ path: { type: 'string', description: 'File or directory path containing spec(s).' },
77
+ config: { type: 'string', description: 'Optional config file, equivalent to `-c`.' },
78
+ out: { type: 'string', description: 'Optional output base path, equivalent to `-o`.' },
79
+ formatters: {
80
+ type: 'array',
81
+ description: 'Optional formatter list.',
82
+ items: {
83
+ type: 'string',
84
+ enum: RunSpecsTool::SUPPORTED_FORMATTERS
85
+ }
86
+ },
87
+ mode: {
88
+ type: 'string',
89
+ enum: RunSpecsTool::SUPPORTED_MODES,
90
+ description: 'Optional output mode.'
91
+ }
92
+ },
93
+ required: ['path']
94
+ }
95
+ }
96
+ end
97
+
98
+ def handle_tool_call(params)
99
+ tool_name = params['name']
100
+ arguments = params['arguments'] || {}
101
+
102
+ return tool_error("Unknown tool: #{tool_name}") unless tool_name == RUN_SPECS_TOOL_NAME
103
+
104
+ result = @run_specs_tool.call(arguments)
105
+
106
+ unless result[:ok]
107
+ return tool_error(result.dig(:logs, :stderr) || 'run_specs failed unexpectedly.')
108
+ end
109
+
110
+ tool_success(result)
111
+ rescue ArgumentError => e
112
+ tool_error(e.message)
113
+ end
114
+
115
+ def tool_success(payload)
116
+ {
117
+ content: [{ type: 'text', text: JSON.pretty_generate(payload) }],
118
+ structuredContent: payload
119
+ }
120
+ end
121
+
122
+ def tool_error(message)
123
+ {
124
+ content: [{ type: 'text', text: message }],
125
+ isError: true
126
+ }
127
+ end
128
+
129
+ def success_response(id, result)
130
+ {
131
+ jsonrpc: JSONRPC_VERSION,
132
+ id: id,
133
+ result: result
134
+ }
135
+ end
136
+
137
+ def error_response(id, code, message)
138
+ {
139
+ jsonrpc: JSONRPC_VERSION,
140
+ id: id,
141
+ error: {
142
+ code: code,
143
+ message: message
144
+ }
145
+ }
146
+ end
147
+
148
+ def read_message
149
+ headers = {}
150
+
151
+ while (line = @input.gets)
152
+ line = line.strip
153
+ break if line.empty?
154
+
155
+ key, value = line.split(':', 2)
156
+ next unless key && value
157
+
158
+ headers[key.downcase] = value.strip
159
+ end
160
+
161
+ return nil if headers.empty?
162
+
163
+ content_length = Integer(headers.fetch('content-length'))
164
+ raw = @input.read(content_length)
165
+ JSON.parse(raw)
166
+ rescue EOFError
167
+ nil
168
+ end
169
+
170
+ def write_message(message)
171
+ payload = JSON.dump(message)
172
+ @output.write("Content-Length: #{payload.bytesize}\r\n\r\n")
173
+ @output.write(payload)
174
+ @output.flush
175
+ end
176
+ end
177
+ end
178
+ end
data/lib/pr_d/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PrD
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -22,17 +22,20 @@ Gem::Specification.new do |spec|
22
22
  spec.files = Dir[
23
23
  "lib/**/*.rb",
24
24
  "bin/prd",
25
+ "bin/prd_mcp",
25
26
  "README.md",
26
27
  "Gemfile",
27
28
  "probatio_diabolica.gemspec"
28
29
  ]
29
30
  spec.bindir = "bin"
30
31
  spec.executables << "prd"
32
+ spec.executables << "prd_mcp"
31
33
  spec.require_paths = ["lib"]
32
34
 
33
35
  spec.add_dependency "ruby_llm"
34
36
  spec.add_dependency 'ruby_llm-schema'
35
37
  spec.add_dependency 'pdf-reader'
36
38
  spec.add_dependency 'prawn'
39
+ spec.add_dependency 'rouge'
37
40
  spec.add_dependency 'zeitwerk'
38
41
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: probatio_diabolica
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Laporte Mathieu
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rouge
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: zeitwerk
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -85,13 +99,16 @@ email:
85
99
  - mathieu.laporte+prd@gmail.com
86
100
  executables:
87
101
  - prd
102
+ - prd_mcp
88
103
  extensions: []
89
104
  extra_rdoc_files: []
90
105
  files:
91
106
  - Gemfile
92
107
  - README.md
93
108
  - bin/prd
109
+ - bin/prd_mcp
94
110
  - lib/pr_d.rb
111
+ - lib/pr_d/code.rb
95
112
  - lib/pr_d/formatters.rb
96
113
  - lib/pr_d/formatters/formatter.rb
97
114
  - lib/pr_d/formatters/html_formatter.rb
@@ -108,6 +125,8 @@ files:
108
125
  - lib/pr_d/matchers/includes_matcher.rb
109
126
  - lib/pr_d/matchers/llm_matcher.rb
110
127
  - lib/pr_d/matchers/matcher.rb
128
+ - lib/pr_d/mcp/run_specs_tool.rb
129
+ - lib/pr_d/mcp/server.rb
111
130
  - lib/pr_d/version.rb
112
131
  - lib/probatio_diabolica.rb
113
132
  - probatio_diabolica.gemspec