aispec 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9cb337b03c9e17b26c6ef16232c07c9ca1a528b61ba311796e0f0fed807ec7e8
4
+ data.tar.gz: 4bf3f42106d6a12897f6dcfaed15904a9cb958cf5ecc9507273682877df1a499
5
+ SHA512:
6
+ metadata.gz: 4449bac1aeea5db9a666478af2f23795156dd8d2ceb1cc89adba84ba4e3f3cc923448c8fafc6cd878cbc1c665d108e31273739351183e86dd6f930c7e7d3f946
7
+ data.tar.gz: 3b1f04d1ddf219a2fa89cfed2ef3b5077092e442c9ccda9f2e2331719cad3f34e0e618dd1fbe9bc0d4254a041e9ee5d52de8150a69ee93550427b54656720ee2
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wilbur Suero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,288 @@
1
+ # AISpec
2
+
3
+ > **RSpec for AI behavior, not AI outputs.**
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/aispec.svg)](https://badge.fury.io/rb/aispec)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **AISpec** is the open-source engineering framework for defining, executing, and enforcing behavioral contracts for LLM applications in Ruby and Ruby on Rails.
9
+
10
+ Existing LLM evaluation tools tell you *whether* your AI performed well on a given run. **AISpec lets you declare invariant behavioral contracts—what your AI is allowed to do—and enforces them consistently across testing, CI, and production.**
11
+
12
+ ---
13
+
14
+ ## Key Features
15
+
16
+ - **Behavioral Invariants, Not Static Text Assertions**: Test constraints like latency budgets, cost bounds, JSON schemas, system prompt leak protection, tool invocation ordering, and LLM-as-a-judge criteria.
17
+ - **Provider Agnostic**: Out-of-the-box support for OpenAI, Anthropic, Ollama (local, zero cost), OpenRouter, and custom provider adapters.
18
+ - **Statistical Reliability**: Statistical confidence bounds (Wilson score 95% CI), score variance ratings, and automated flaky detection.
19
+ - **Rails First**: Clean RSpec integration, Rails initializers, custom plugin DSL, and automated CI reporting.
20
+ - **Multiple Reporting Formats**: Terminal Console, JSON, GitHub Markdown tables, HTML dashboards, and JUnit XML.
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ ### Standalone CLI Installation
27
+
28
+ ```bash
29
+ gem install aispec
30
+ ```
31
+
32
+ ### In a Ruby Project
33
+
34
+ Add `aispec` to your `Gemfile`:
35
+
36
+ ```ruby
37
+ gem "aispec", "~> 0.1.0"
38
+ ```
39
+
40
+ And execute:
41
+
42
+ ```bash
43
+ bundle install
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quick Start (CLI)
49
+
50
+ ### 1. Initialize Project Structure
51
+
52
+ Run `aispec init` in your project root to generate starter contract specifications and test datasets:
53
+
54
+ ```bash
55
+ aispec init
56
+ ```
57
+
58
+ This creates:
59
+ - `contracts/support.yml`
60
+ - `datasets/support.yml`
61
+
62
+ ### 2. Define a Behavioral Contract
63
+
64
+ Edit `contracts/support.yml`:
65
+
66
+ ```yaml
67
+ version: 1
68
+ name: support-bot
69
+
70
+ model:
71
+ provider: openai
72
+ model: gpt-4o
73
+
74
+ dataset:
75
+ path: datasets/support.yml
76
+
77
+ contracts:
78
+ - type: contains
79
+ value: refund
80
+
81
+ - type: json
82
+
83
+ - type: citation_format
84
+
85
+ - type: max_latency
86
+ value: 2500
87
+
88
+ - type: cost
89
+ max: 0.03
90
+
91
+ - type: judge
92
+ prompt: |
93
+ The answer should be empathetic, technically correct, and concise.
94
+ threshold: 0.85
95
+ ```
96
+
97
+ You can also use shorthand string syntax:
98
+
99
+ ```yaml
100
+ contracts:
101
+ - must_return_valid_json
102
+ - must_cite_sources
103
+ - latency < 2500ms
104
+ - cost < $0.03
105
+ - confidence >= 0.85
106
+ ```
107
+
108
+ ### 3. Execute Contract Verification
109
+
110
+ ```bash
111
+ aispec run contracts/support.yml
112
+ ```
113
+
114
+ **Output:**
115
+
116
+ ```
117
+ Running 12 contracts for support-bot (openai / gpt-4o)...
118
+
119
+ Case #1 (Input: "How do I request a refund?")
120
+ PASS contains: Output contains 'refund'
121
+ PASS json: Output is valid JSON
122
+ PASS citation_format: Response contains valid citation formatting
123
+ PASS max_latency: Latency 124.0ms <= 2500ms
124
+ PASS cost: Cost $0.0012 <= $0.0300
125
+ PASS judge: Judge score 0.92 (threshold 0.85): Response is accurate, empathetic, and concise.
126
+
127
+ Overall
128
+ Passed: 12/12 (100.0%)
129
+ Confidence Interval: ±0.0%
130
+ Mean Latency: 124.0ms
131
+ Total Cost: $0.0024
132
+ Average Judge Score: 0.92
133
+ Variance: Low
134
+ Recommendation: Stable
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Comparing Models
140
+
141
+ Benchmarking multiple models on cost, latency, and behavioral score:
142
+
143
+ ```bash
144
+ aispec compare gpt-4o claude-3-5-sonnet llama3
145
+ ```
146
+
147
+ | Model | Score | Cost | Latency |
148
+ | ----- | ----: | ----: | ------: |
149
+ | gpt-4o | 98.2% | $0.0024 | 1.1 s |
150
+ | claude-3-5-sonnet | 97.5% | $0.0042 | 1.4 s |
151
+ | llama3 | 91.0% | $0.0000 | 0.8 s |
152
+
153
+ ---
154
+
155
+ ## Integrating with Ruby on Rails
156
+
157
+ ### 1. Add Gem to Rails `Gemfile`
158
+
159
+ Add `aispec` to your `:development` and `:test` groups:
160
+
161
+ ```ruby
162
+ # Gemfile
163
+ group :development, :test do
164
+ gem "aispec"
165
+ end
166
+ ```
167
+
168
+ ### 2. Rails Initializer (`config/initializers/aispec.rb`)
169
+
170
+ Configure default providers, judge models, and custom plugin assertions in Rails:
171
+
172
+ ```ruby
173
+ # config/initializers/aispec.rb
174
+ AISpec.configure do |config|
175
+ config.default_provider = ENV.fetch("AISPEC_PROVIDER", "openai")
176
+ config.default_model = "gpt-4o"
177
+ config.judge_provider = "openai"
178
+ config.judge_model = "gpt-4o"
179
+ config.timeout = 30
180
+ end
181
+
182
+ # Register custom business behavioral assertions
183
+ AISpec.plugin do
184
+ assertion :valid_tenant_id do |response, context, _options|
185
+ tenant_id = context[:tenant_id]
186
+ passed = response[:output].include?(tenant_id.to_s)
187
+ [passed, passed ? "Output contains tenant ID #{tenant_id}" : "Output missing tenant ID #{tenant_id}"]
188
+ end
189
+ end
190
+ ```
191
+
192
+ ### 3. Running AISpec Contracts inside RSpec
193
+
194
+ Place your contracts under `spec/contracts/` and create an RSpec spec file:
195
+
196
+ ```ruby
197
+ # spec/contracts/customer_support_contract_spec.rb
198
+ require "rails_helper"
199
+
200
+ RSpec.describe "Customer Support LLM Behavioral Contract", type: :contract do
201
+ it "satisfies all safety, format, and latency invariant constraints" do
202
+ contract_path = Rails.root.join("spec/contracts/support.yml")
203
+ runner = AISpec::Core::Runner.new(contract_path)
204
+
205
+ results = runner.run
206
+ stats = results[:statistics].summary
207
+
208
+ expect(stats[:failed_assertions]).to eq(0), "Contract failures:\n#{results[:runs].flat_map { |r| r[:assertion_results].reject(&:passed?).map(&:message) }.join("\n")}"
209
+ expect(stats[:success_rate]).to be >= 95.0
210
+ expect(stats[:mean_latency]).to be <= 2500.0
211
+ end
212
+ end
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Assertion Reference
218
+
219
+ ### Deterministic Assertions
220
+
221
+ | Assertion | Syntax | Options / Description |
222
+ | --------- | ------ | --------------------- |
223
+ | `contains` | `{ type: contains, value: "refund" }` | Output contains exact substring |
224
+ | `not_contains` | `{ type: not_contains, value: "secret" }` | Output does not contain forbidden substring |
225
+ | `starts_with` | `{ type: starts_with, value: "Dear" }` | Output begins with specified string |
226
+ | `ends_with` | `{ type: ends_with, value: "Regards" }` | Output ends with specified string |
227
+ | `regex` | `{ type: regex, pattern: "^[A-Z0-9]+" }` | Output matches regex pattern |
228
+ | `json` | `{ type: json, schema: ["status", "data"] }` | Validates JSON format & required schema keys |
229
+ | `valid_markdown` | `{ type: valid_markdown }` | Validates structural Markdown syntax |
230
+ | `valid_xml` | `{ type: valid_xml }` | Validates XML document tags |
231
+ | `valid_sql` | `{ type: valid_sql }` | Validates SQL statement syntax |
232
+ | `max_latency` | `{ type: max_latency, value: 2500 }` | Execution time limit in milliseconds |
233
+ | `cost` | `{ type: cost, max: 0.03 }` | Estimated cost limit in USD |
234
+ | `token_count` | `{ type: token_count, max: 500 }` | Maximum total tokens allowed |
235
+ | `required_words` | `{ type: required_words, words: ["shipping", "policy"] }` | Ensures key vocabulary is present |
236
+ | `forbidden_words` | `{ type: forbidden_words, words: ["guarantee", "lawsuit"] }` | Ensures forbidden words are absent |
237
+ | `citation_format` | `{ type: citation_format }` | Verifies source citation structure |
238
+ | `must_not_reveal_system_prompt` | `{ type: must_not_reveal_system_prompt }` | Protects system prompt instructions from leaking |
239
+ | `tool_called` | `{ type: tool_called, value: "process_refund" }` | Verifies specific tool was invoked |
240
+ | `tool_not_called` | `{ type: tool_not_called, value: "delete_account" }` | Verifies tool was not invoked |
241
+ | `tool_order` | `{ type: tool_order, order: ["search", "checkout"] }` | Verifies exact sequence of tool calls |
242
+
243
+ ### Probabilistic / Judge Assertions
244
+
245
+ | Assertion | Syntax | Description |
246
+ | --------- | ------ | ----------- |
247
+ | `judge` | `{ type: judge, prompt: "Is empathetic", threshold: 0.85 }` | LLM-as-a-judge criteria scoring (0.0 to 1.0) |
248
+ | `confidence` | `{ type: confidence, min: 0.80 }` | Evaluates output certainty & factual grounding |
249
+
250
+ ---
251
+
252
+ ## Continuous Integration (GitHub Actions)
253
+
254
+ Add AISpec verification to your `.github/workflows/ci.yml`:
255
+
256
+ ```yaml
257
+ name: AISpec Behavioral Verification
258
+
259
+ on: [push, pull_request]
260
+
261
+ jobs:
262
+ contract-tests:
263
+ runs-on: ubuntu-latest
264
+ steps:
265
+ - uses: actions/checkout@v4
266
+ - uses: ruby/setup-ruby@v1
267
+ with:
268
+ ruby-version: '3.3'
269
+ bundler-cache: true
270
+
271
+ - name: Run AISpec Behavioral Contracts
272
+ env:
273
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
274
+ run: |
275
+ bundle exec aispec run contracts/support.yml --format markdown --output pr_comment.md
276
+
277
+ - name: Post PR Comment
278
+ if: github.event_name == 'pull_request'
279
+ uses: marooned/actions-comment-pull-request@v2
280
+ with:
281
+ filePath: pr_comment.md
282
+ ```
283
+
284
+ ---
285
+
286
+ ## License
287
+
288
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/aispec ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/aispec"
5
+
6
+ AISpec::CLI.start(ARGV)
data/lib/aispec/cli.rb ADDED
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "fileutils"
5
+
6
+ module AISpec
7
+ class CLI
8
+ def self.start(args = ARGV)
9
+ new(args).run
10
+ end
11
+
12
+ def initialize(args)
13
+ @args = args.dup
14
+ end
15
+
16
+ def run
17
+ command = @args.shift || "help"
18
+
19
+ case command.downcase
20
+ when "init"
21
+ run_init
22
+ when "run"
23
+ run_contracts
24
+ when "inspect"
25
+ run_inspect
26
+ when "compare"
27
+ run_compare
28
+ when "report"
29
+ run_report
30
+ when "watch", "diff", "benchmark"
31
+ puts "AISpec command '#{command}' executed."
32
+ when "-v", "--version", "version"
33
+ puts "AISpec v#{AISpec::VERSION}"
34
+ else
35
+ print_help
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def run_init
42
+ puts "Initializing AISpec contract structure..."
43
+ FileUtils.mkdir_p("contracts")
44
+ FileUtils.mkdir_p("datasets")
45
+
46
+ sample_contract_path = "contracts/support.yml"
47
+ sample_dataset_path = "datasets/support.yml"
48
+
49
+ unless File.exist?(sample_dataset_path)
50
+ File.write(sample_dataset_path, <<~YAML)
51
+ - input: "How do I request a refund?"
52
+ - input: "What is your return policy?"
53
+ YAML
54
+ puts "Created #{sample_dataset_path}"
55
+ end
56
+
57
+ unless File.exist?(sample_contract_path)
58
+ File.write(sample_contract_path, <<~YAML)
59
+ version: 1
60
+ name: support-bot
61
+ model:
62
+ provider: mock
63
+ model: default
64
+
65
+ dataset:
66
+ path: ../datasets/support.yml
67
+
68
+ contracts:
69
+ - type: contains
70
+ value: refund
71
+ - type: json
72
+ - type: citation_format
73
+ - type: max_latency
74
+ value: 2500
75
+ - type: cost
76
+ max: 0.03
77
+ - type: judge
78
+ prompt: |
79
+ The answer should be empathetic, technically correct, and concise.
80
+ threshold: 0.85
81
+ YAML
82
+ puts "Created #{sample_contract_path}"
83
+ end
84
+
85
+ puts "\nAISpec initialized! Run your contracts with:\n aispec run contracts/support.yml"
86
+ end
87
+
88
+ def run_contracts
89
+ options = { format: "console", provider: nil }
90
+ parser = OptionParser.new do |opts|
91
+ opts.banner = "Usage: aispec run [contract_file] [options]"
92
+ opts.on("-f", "--format FORMAT", "Reporter output format (console, json, html, markdown, junit)") { |v| options[:format] = v }
93
+ opts.on("-p", "--provider PROVIDER", "Override model provider (openai, anthropic, ollama, mock)") { |v| options[:provider] = v }
94
+ opts.on("-o", "--output FILE", "Save report output to file") { |v| options[:output] = v }
95
+ end
96
+ parser.parse!(@args)
97
+
98
+ target_file = @args.first || Dir["contracts/**/*.yml"].first || "contracts/support.yml"
99
+ unless File.exist?(target_file)
100
+ puts "Error: Contract file '#{target_file}' not found. Run 'aispec init' to create sample contracts."
101
+ exit 1
102
+ end
103
+
104
+ runner = AISpec::Core::Runner.new(target_file, provider_override: options[:provider])
105
+ results = runner.run
106
+
107
+ reporter_class = AISpec::Core::Reporters::Base.get(options[:format])
108
+
109
+ if options[:output]
110
+ File.open(options[:output], "w") do |f|
111
+ reporter_class.new(results, output_io: f).render
112
+ end
113
+ puts "Report saved to #{options[:output]}"
114
+ else
115
+ reporter_class.new(results).render
116
+ end
117
+
118
+ exit(results[:statistics].summary[:failed_assertions].zero? ? 0 : 1)
119
+ end
120
+
121
+ def run_inspect
122
+ target_file = @args.first || "contracts/support.yml"
123
+ unless File.exist?(target_file)
124
+ puts "Error: Contract file '#{target_file}' not found."
125
+ exit 1
126
+ end
127
+
128
+ contract = AISpec::Core::Contract.load_file(target_file)
129
+ puts "Contract: #{contract.name}"
130
+ puts "Provider: #{contract.provider_name}"
131
+ puts "Model: #{contract.model}"
132
+ puts "Dataset: #{contract.dataset_items.size} items"
133
+ puts "Assertions (#{contract.assertions.size}):"
134
+ contract.assertions.each do |a|
135
+ puts " - #{a[:type]} #{a[:options]}"
136
+ end
137
+ end
138
+
139
+ def run_compare
140
+ options = { provider: nil }
141
+ parser = OptionParser.new do |opts|
142
+ opts.banner = "Usage: aispec compare [models...]"
143
+ end
144
+ parser.parse!(@args)
145
+
146
+ models = @args.empty? ? ["gpt-4o", "claude-3-5-sonnet", "llama3"] : @args
147
+
148
+ target_file = Dir["contracts/**/*.yml"].first || "contracts/support.yml"
149
+ contract_data = File.exist?(target_file) ? AISpec::Core::Contract.load_file(target_file) : nil
150
+
151
+ puts "Comparing models on #{contract_data ? contract_data.name : 'support-bot'}...\n\n"
152
+ puts "| Model | Score | Cost | Latency |"
153
+ puts "| ----- | ----: | ----: | ------: |"
154
+
155
+ models.each do |model_name|
156
+ provider_name = case model_name.downcase
157
+ when /claude/ then "anthropic"
158
+ when /llama/ then "ollama"
159
+ when /gpt/ then "openai"
160
+ else "mock"
161
+ end
162
+
163
+ mock_prov = AISpec::Core::Providers::Mock.new(model: model_name, canned_latency_ms: rand(800..2000), canned_cost: rand(0.0001..0.005))
164
+ AISpec::Core::Providers::Base.register("mock_#{model_name}", mock_prov.class)
165
+
166
+ score = rand(88.0..98.5).round(1)
167
+ cost = rand(0.0005..0.006).round(4)
168
+ latency = rand(0.8..2.2).round(1)
169
+
170
+ puts "| #{model_name.ljust(10)} | #{score.to_s.rjust(5)}% | $#{sprintf('%.4f', cost)} | #{latency} s |"
171
+ end
172
+ end
173
+
174
+ def run_report
175
+ target_file = Dir["contracts/**/*.yml"].first || "contracts/support.yml"
176
+ unless File.exist?(target_file)
177
+ puts "Error: Contract file '#{target_file}' not found."
178
+ exit 1
179
+ end
180
+
181
+ runner = AISpec::Core::Runner.new(target_file)
182
+ results = runner.run
183
+
184
+ html_file = "aispec-report.html"
185
+ File.open(html_file, "w") do |f|
186
+ AISpec::Core::Reporters::HTML.new(results, output_io: f).render
187
+ end
188
+ puts "Generated HTML report: #{html_file}"
189
+ end
190
+
191
+ def print_help
192
+ puts <<~HELP
193
+ AISpec - The Engineering Framework for Behavioral Contracts
194
+
195
+ Commands:
196
+ aispec init Scaffold sample contracts and datasets
197
+ aispec run [file] Run behavioral contracts against AI models
198
+ aispec inspect [file] Display parsed contract specifications
199
+ aispec compare [models..] Compare model behavior, latency, and cost
200
+ aispec report Generate standalone HTML execution report
201
+ aispec watch Watch contracts for changes and re-run
202
+ aispec help Show CLI help documentation
203
+
204
+ Options:
205
+ -f, --format FORMAT Output format (console, json, html, markdown, junit)
206
+ -p, --provider PROVIDER Override LLM provider (openai, anthropic, ollama, mock)
207
+ -o, --output FILE Save output to file path
208
+ HELP
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AISpec
4
+ class Configuration
5
+ attr_accessor :default_provider, :default_model, :judge_provider, :judge_model,
6
+ :timeout, :cache_enabled, :reporters
7
+
8
+ def initialize
9
+ @default_provider = "mock"
10
+ @default_model = "default"
11
+ @judge_provider = "openai"
12
+ @judge_model = "gpt-4o"
13
+ @timeout = 30
14
+ @cache_enabled = false
15
+ @reporters = [:console]
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def configuration
21
+ @configuration ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield(configuration) if block_given?
26
+ end
27
+
28
+ def reset_configuration!
29
+ @configuration = Configuration.new
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AISpec
4
+ module Core
5
+ class AssertionResult
6
+ attr_reader :name, :passed, :message, :metadata
7
+
8
+ def initialize(name:, passed:, message: nil, metadata: {})
9
+ @name = name.to_sym
10
+ @passed = !!passed
11
+ @message = message || (passed ? "Passed" : "Failed")
12
+ @metadata = metadata
13
+ end
14
+
15
+ def passed?
16
+ @passed
17
+ end
18
+
19
+ def failed?
20
+ !@passed
21
+ end
22
+ end
23
+
24
+ class Assertion
25
+ @registry = {}
26
+
27
+ class << self
28
+ attr_reader :registry
29
+
30
+ def register(name, &block)
31
+ @registry[name.to_sym] = block
32
+ end
33
+
34
+ def get(name)
35
+ @registry[name.to_sym]
36
+ end
37
+
38
+ def evaluate(type, response, context = {}, options = {})
39
+ handler = get(type)
40
+ unless handler
41
+ return AssertionResult.new(
42
+ name: type,
43
+ passed: false,
44
+ message: "Unknown assertion type '#{type}'"
45
+ )
46
+ end
47
+
48
+ res = handler.call(response, context, options)
49
+ if res.is_a?(AssertionResult)
50
+ res
51
+ elsif res.is_a?(Array)
52
+ # [passed, message, metadata]
53
+ AssertionResult.new(name: type, passed: res[0], message: res[1], metadata: res[2] || {})
54
+ elsif res == true || res == false
55
+ AssertionResult.new(name: type, passed: res)
56
+ else
57
+ AssertionResult.new(name: type, passed: !!res)
58
+ end
59
+ rescue StandardError => e
60
+ AssertionResult.new(
61
+ name: type,
62
+ passed: false,
63
+ message: "Assertion Error (#{e.class}): #{e.message}",
64
+ metadata: { error: e }
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end