squishling 0.4.0 → 0.5.1

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/README.md +26 -10
  4. data/docs/adapters.md +257 -0
  5. data/docs/configuration.md +54 -19
  6. data/docs/failures.md +170 -22
  7. data/docs/harnesses.md +39 -15
  8. data/docs/measuring-tokens.md +12 -6
  9. data/docs/observers.md +3 -3
  10. data/docs/routing.md +14 -3
  11. data/docs/schemas.md +24 -2
  12. data/docs/testing.md +187 -0
  13. data/docs/verifying.md +86 -0
  14. data/lib/squishling/adapter/error_body.rb +54 -0
  15. data/lib/squishling/adapter.rb +328 -0
  16. data/lib/squishling/adapters/openai_compatible.rb +311 -0
  17. data/lib/squishling/adapters/openai_options.rb +114 -0
  18. data/lib/squishling/adapters/openai_transport.rb +123 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +229 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +58 -6
  24. data/lib/squishling/definition.rb +90 -16
  25. data/lib/squishling/errors.rb +94 -7
  26. data/lib/squishling/harness/judged_squishsum.rb +2 -2
  27. data/lib/squishling/harness/option_types.rb +2 -1
  28. data/lib/squishling/harness/registry.rb +8 -36
  29. data/lib/squishling/invoker.rb +44 -14
  30. data/lib/squishling/judge.rb +16 -3
  31. data/lib/squishling/llm_client.rb +66 -132
  32. data/lib/squishling/model_path.rb +60 -13
  33. data/lib/squishling/named_registry.rb +64 -0
  34. data/lib/squishling/observer.rb +13 -4
  35. data/lib/squishling/output_check.rb +13 -9
  36. data/lib/squishling/params.rb +7 -24
  37. data/lib/squishling/railtie.rb +10 -0
  38. data/lib/squishling/result.rb +50 -16
  39. data/lib/squishling/rotation.rb +5 -0
  40. data/lib/squishling/router.rb +3 -2
  41. data/lib/squishling/rspec.rb +18 -0
  42. data/lib/squishling/run/messages.rb +40 -3
  43. data/lib/squishling/run/rejections.rb +43 -0
  44. data/lib/squishling/run/reports.rb +63 -0
  45. data/lib/squishling/run.rb +60 -53
  46. data/lib/squishling/schema.rb +50 -9
  47. data/lib/squishling/secret.rb +45 -0
  48. data/lib/squishling/squawk.rb +37 -4
  49. data/lib/squishling/tasks/squishling.rake +18 -0
  50. data/lib/squishling/testing/adapter.rb +194 -0
  51. data/lib/squishling/testing/checks.rb +39 -0
  52. data/lib/squishling/testing/direct.rb +41 -0
  53. data/lib/squishling/testing/errors.rb +18 -0
  54. data/lib/squishling/testing/fixtures.rb +206 -0
  55. data/lib/squishling/testing/helpers.rb +71 -0
  56. data/lib/squishling/testing/minitest.rb +32 -0
  57. data/lib/squishling/testing/observer.rb +31 -0
  58. data/lib/squishling/testing/reply.rb +60 -0
  59. data/lib/squishling/testing/request.rb +75 -0
  60. data/lib/squishling/testing/rspec.rb +30 -0
  61. data/lib/squishling/testing/script.rb +189 -0
  62. data/lib/squishling/testing/state.rb +74 -0
  63. data/lib/squishling/testing/stubs.rb +112 -0
  64. data/lib/squishling/testing.rb +126 -0
  65. data/lib/squishling/verification.rb +39 -0
  66. data/lib/squishling/verifier.rb +179 -0
  67. data/lib/squishling/version.rb +1 -1
  68. data/lib/squishling/wrapper.rb +19 -1
  69. data/lib/squishling.rb +60 -4
  70. metadata +43 -21
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # One scripted line: the requests it answers and what they get, in order. The last answer repeats unless the
6
+ # rule is #once. Build them through Script (the block Testing.install yields).
7
+ class Rule
8
+ # What a rule answers. nil means any. kind is :chat (Adapter#complete) or :judgment (Adapter#judge); role is
9
+ # nil (an escalation attempt, or any role when the rule names none), :sample, or :judge.
10
+ Selector = Data.define(:kind, :role, :sample, :model, :provider, :attempt)
11
+
12
+ # Rule#take when nothing is left to answer with.
13
+ EXHAUSTED = Object.new.freeze
14
+
15
+ attr_reader :selector
16
+
17
+ def initialize(selector)
18
+ @selector = selector
19
+ @replies = []
20
+ @taken = 0
21
+ @once = false
22
+ end
23
+
24
+ # Answers, in order: a Hash (the model's answer), a String (raw text, malformed or fenced JSON included), nil
25
+ # (an empty response), a Reply (with a model id and token usage), or an Exception (raised as is).
26
+ def reply(*replies)
27
+ @replies.concat(replies)
28
+ self
29
+ end
30
+
31
+ # A provider failure named by intent (see Failures::INTENTS).
32
+ def fail(intent)
33
+ @replies << Failure.new(Failures.check!(intent))
34
+ self
35
+ end
36
+
37
+ # Running out of answers is an error instead of repeating the last one.
38
+ def once
39
+ @once = true
40
+ self
41
+ end
42
+
43
+ def matches?(kind, call)
44
+ selector.kind == kind && fits?(selector.role, call[:role]) && fits?(selector.sample, call[:sample]) &&
45
+ fits?(selector.model, call[:model]) && fits?(selector.provider, call[:provider]) &&
46
+ fits?(selector.attempt, call[:attempt])
47
+ end
48
+
49
+ # How narrowly the rule aims, lowest first: rules for a sample or the judge, then those naming the most of a
50
+ # model, a provider, and an attempt, then the rest.
51
+ def specificity
52
+ return 0 if selector.sample || selector.role
53
+
54
+ 4 - [selector.model, selector.provider, selector.attempt].count { |named| !named.nil? }
55
+ end
56
+
57
+ # The next answer, or EXHAUSTED. Called with the script's lock held.
58
+ def take
59
+ return EXHAUSTED if @replies.empty? || (@once && @taken >= @replies.size)
60
+
61
+ @replies[[@taken, @replies.size - 1].min].tap { @taken += 1 }
62
+ end
63
+
64
+ def empty?
65
+ @replies.empty?
66
+ end
67
+
68
+ def describe
69
+ aimed = selector.to_h.compact.except(:kind).map { |key, value| "#{key}: #{value.inspect}" }
70
+ "#{selector.kind} #{aimed.empty? ? 'any' : aimed.join(', ')}"
71
+ end
72
+
73
+ private
74
+
75
+ def fits?(wanted, actual)
76
+ wanted.nil? || wanted.to_s == actual.to_s
77
+ end
78
+ end
79
+
80
+ # The judge's rules: a chat judge answers a verdict, a System One judge a judgment with a confidence.
81
+ class JudgeRules
82
+ CHOICES = %i[a b neither].freeze
83
+
84
+ def initialize(chat, judgment)
85
+ @chat = chat
86
+ @judgment = judgment
87
+ end
88
+
89
+ # A chat judge's strict verdict (:a, :b, or :neither) and its reason.
90
+ def verdict(choice, reason: "scripted verdict")
91
+ @chat.reply({ "verdict" => check(choice).to_s, "reason" => reason })
92
+ self
93
+ end
94
+
95
+ # A System One judgment (type: :judgment) choosing :a, :b, or :neither with that probability; the rest of the
96
+ # probability is split between the other two choices. Pass an Exception to raise it instead.
97
+ def judgment(choice, confidence: 0.95)
98
+ unless choice.is_a?(Exception) || confidence.between?(0, 1)
99
+ raise ArgumentError, "confidence is a probability from 0 to 1, got #{confidence.inspect}"
100
+ end
101
+
102
+ @judgment.reply(choice.is_a?(Exception) ? choice : answer(check(choice), confidence))
103
+ self
104
+ end
105
+
106
+ # Anything a chat judge's request can answer with (see Rule#reply): off-schema verdicts, raw text, ...
107
+ def reply(*replies)
108
+ @chat.reply(*replies)
109
+ self
110
+ end
111
+
112
+ def fail(intent)
113
+ @chat.fail(intent)
114
+ self
115
+ end
116
+
117
+ def fail_judgment(intent)
118
+ @judgment.fail(intent)
119
+ self
120
+ end
121
+
122
+ def once
123
+ @chat.once
124
+ @judgment.once
125
+ self
126
+ end
127
+
128
+ private
129
+
130
+ def check(choice)
131
+ return choice.to_sym if CHOICES.include?(choice.to_s.to_sym)
132
+
133
+ raise ArgumentError, "a judge choice is one of #{CHOICES.map(&:inspect).join(', ')}, got #{choice.inspect}"
134
+ end
135
+
136
+ def answer(choice, confidence)
137
+ others = (1 - confidence) / 2.0
138
+ probabilities = CHOICES.to_h { |key| [key, key == choice ? confidence.to_f : others] }
139
+ Judgment.new({ winner: Answer.new(choice, probabilities) })
140
+ end
141
+ end
142
+
143
+ # What a test says the model will do. Rules are matched most specific first (a sample or the judge, then a
144
+ # named model, then step(:any)), and by declaration order within that.
145
+ #
146
+ # llm.step("flash").reply({ score: 99 }, { score: 5 }) # garbage, then an answer
147
+ # llm.step("pro").fail(:rate_limit)
148
+ # llm.sample("a").reply(valid_a)
149
+ # llm.judge.verdict(:a)
150
+ class Script
151
+ def initialize
152
+ @rules = []
153
+ end
154
+
155
+ # Escalation attempts (and any other chat request no sample or judge rule claims) on a model, a provider, or
156
+ # :any model; attempt: narrows it to one attempt of the escalation (counting from 1).
157
+ def step(model = :any, provider: nil, attempt: nil)
158
+ add(:chat, model: model == :any ? nil : model, provider:, attempt:)
159
+ end
160
+
161
+ # A harness sample's requests ("a", "b", ...). Each sample has its own queue, so the order the sample threads
162
+ # happen to run in can't change what a sample gets.
163
+ def sample(name)
164
+ add(:chat, role: :sample, sample: name.to_s)
165
+ end
166
+
167
+ # The judge of :judged_squishsum, chat or System One.
168
+ def judge
169
+ @judge ||= JudgeRules.new(add(:chat, role: :judge), add(:judgment, role: :judge))
170
+ end
171
+
172
+ # The rule that answers a request of this kind, or nil. Called with the lock held (see Adapter).
173
+ def rule_for(kind, call)
174
+ candidates = @rules.each_with_index.select { |rule, _| rule.matches?(kind, call) }
175
+ candidates.min_by { |rule, index| [rule.specificity, index] }&.first
176
+ end
177
+
178
+ def describe
179
+ @rules.empty? ? "nothing" : @rules.map(&:describe).join("; ")
180
+ end
181
+
182
+ private
183
+
184
+ def add(kind, role: nil, sample: nil, model: nil, provider: nil, attempt: nil)
185
+ Rule.new(Rule::Selector.new(kind:, role:, sample:, model:, provider:, attempt:)).tap { |rule| @rules << rule }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # What the helpers changed in Squishling.config (the adapter and observer), remembered so Testing.reset! puts
6
+ # back exactly that: not an app's other settings (which Squishling.reset_config! would also clear), and not a
7
+ # value a test assigned itself after the helpers did.
8
+ module State
9
+ LOCK = Mutex.new
10
+ private_constant :LOCK
11
+
12
+ @entries = {} # setting => [what it was before the helpers, what the helpers set it to]
13
+ @config = nil
14
+
15
+ class << self
16
+ # Sets a Squishling.config setting, remembering what it was before the first change.
17
+ def set(setting, value)
18
+ LOCK.synchronize do
19
+ config = Squishling.config
20
+ @entries = {} unless config.equal?(@config) # a replaced config is already pristine
21
+ @config = config
22
+ original = original_of(config, setting)
23
+ config.public_send(:"#{setting}=", value)
24
+ @entries[setting] = [original, value]
25
+ end
26
+ end
27
+
28
+ # Puts back the settings still holding what the helpers set; leaves a config that has been replaced alone.
29
+ def restore!
30
+ config, entries = LOCK.synchronize do
31
+ [@config, @entries].tap do
32
+ @entries = {}
33
+ @config = nil
34
+ end
35
+ end
36
+ return unless config.equal?(Squishling.config)
37
+
38
+ entries.each do |setting, (original, installed)|
39
+ config.public_send(:"#{setting}=", original) if config.public_send(setting).equal?(installed)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # What to put back: the value before the helpers' first change, unless something else was assigned since.
46
+ def original_of(config, setting)
47
+ current = config.public_send(setting)
48
+ entry = @entries[setting]
49
+ entry && current.equal?(entry.last) ? entry.first : current
50
+ end
51
+ end
52
+ end
53
+
54
+ # What a test asked squawk to record: every attempt's raw output, error, and metadata (role, sample, attempt,
55
+ # final, usage, ...), and the report of a call stopped by a token budget or a disagreement (stopped).
56
+ # Use Testing.with_squawk, which puts the previous hook back.
57
+ class SquawkCapture
58
+ Call = Data.define(:output, :error, :metadata)
59
+
60
+ def initialize
61
+ @lock = Mutex.new
62
+ @calls = []
63
+ end
64
+
65
+ def call(output: nil, error: nil, metadata: nil)
66
+ @lock.synchronize { @calls << Call.new(output:, error:, metadata:) }
67
+ end
68
+
69
+ def calls
70
+ @lock.synchronize { @calls.dup }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ module Testing
5
+ # Replaces a squished method for the tests of its callers: the method returns (or raises) what the test says and
6
+ # never reaches Squishling, but what it returns is a real typed result, validated against the method's output
7
+ # schema, so the stand-in can't drift from the real result type. Cleared by Testing.reset!.
8
+ module Stubs
9
+ LOCK = Mutex.new
10
+ private_constant :LOCK
11
+
12
+ # [class, method name] => callable producing the result, or raising the error
13
+ @active = {}
14
+ # [class, method name] => the module prepended to the class. A prepended module can't be removed, so it stays
15
+ # (inert once its stub is cleared) and keeps its class alive: stub the classes of a suite, not one per test.
16
+ @hooks = {}
17
+
18
+ ERRORS = {
19
+ invalid_output: :invalid_output_error, llm_error: :llm_error, configuration: :configuration_error,
20
+ disagreement: :disagreement_error
21
+ }.freeze
22
+
23
+ class << self
24
+ # A result of the method's schema, with these attributes (squished: false for one the Ruby path returned).
25
+ def result(klass, attrs, method: :call, squished: true, &)
26
+ definition = klass.squishling_definition(method)
27
+ built = typed(definition, attrs, squished:)
28
+ activate(klass, method, -> { built }, &)
29
+ end
30
+
31
+ # The method raises this kind of error: :invalid_output (errors:, raw:, attempts:, models:), :llm_error
32
+ # (message:), :configuration (message:), or :disagreement (candidates:, verdict:, reason:, models:).
33
+ def error(klass, kind, method: :call, **options, &)
34
+ builder = ERRORS.fetch(kind) do
35
+ raise ArgumentError, "unknown error #{kind.inspect} (one of #{ERRORS.keys.map(&:inspect).join(', ')})"
36
+ end
37
+ definition = klass.squishling_definition(method)
38
+ # Built once now, so a mistyped option fails here and not in the code under test.
39
+ send(builder, definition, **options)
40
+ activate(klass, method, -> { raise send(builder, definition, **options) }, &)
41
+ end
42
+
43
+ def clear!
44
+ LOCK.synchronize { @active.clear }
45
+ end
46
+
47
+ # The stub for a method, called by the module prepended to its class (nil when there is none).
48
+ def stub_for(klass, method)
49
+ LOCK.synchronize { @active[[klass, method]] }
50
+ end
51
+
52
+ private
53
+
54
+ def activate(klass, method, callable)
55
+ previous = LOCK.synchronize { @active.delete([klass, method]).tap { @active[[klass, method]] = callable } }
56
+ hook(klass, method)
57
+ return klass unless block_given?
58
+
59
+ begin
60
+ yield
61
+ ensure
62
+ LOCK.synchronize { previous ? @active[[klass, method]] = previous : @active.delete([klass, method]) }
63
+ end
64
+ end
65
+
66
+ # Prepended once per method: calls the stub while there is one, and the real method otherwise.
67
+ def hook(klass, method)
68
+ LOCK.synchronize { @hooks[[klass, method]] ||= build_hook(klass, method) }
69
+ end
70
+
71
+ # A Wrapper, so Wrapper.implementation looks past it to the real method (its parameter names name the
72
+ # arguments the model is sent) once the stub is gone.
73
+ def build_hook(klass, method)
74
+ hook = Wrapper.new
75
+ hook.define_method(method) do |*args, **kwargs, &block|
76
+ stub = Stubs.stub_for(klass, method) if instance_of?(klass) # a subclass keeps its own behavior
77
+ stub ? stub.call : super(*args, **kwargs, &block)
78
+ end
79
+ klass.prepend(hook)
80
+ hook
81
+ end
82
+
83
+ # The attributes as the method's own typed result, validated by the same path as a Ruby-path return.
84
+ def typed(definition, attrs, squished:)
85
+ definition.build_result(attrs, squished:)
86
+ rescue InvalidOutputError => e
87
+ raise SetupError, "#{definition.label}: the stubbed result doesn't match its output_schema: " \
88
+ "#{e.errors.join('; ')}"
89
+ rescue ConfigurationError
90
+ raise SetupError, "#{definition.label} has no output_schema to build a result from"
91
+ end
92
+
93
+ def invalid_output_error(_definition, errors: ["stubbed invalid output"], raw: nil, attempts: 1, models: nil)
94
+ InvalidOutputError.new(errors: Array(errors), raw:, attempts:, models: models || ([nil] * attempts))
95
+ end
96
+
97
+ def llm_error(definition, message: "stubbed LLM failure")
98
+ LLMError.new("#{definition.label}: #{message}")
99
+ end
100
+
101
+ def configuration_error(definition, message: "stubbed configuration error")
102
+ ConfigurationError.new("#{definition.label}: #{message}")
103
+ end
104
+
105
+ def disagreement_error(definition, candidates:, verdict: nil, reason: nil, models: nil)
106
+ typed = candidates.map { |candidate| typed(definition, candidate, squished: true) }
107
+ DisagreementError.new(candidates: typed, verdict:, reason:, models:)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "squishling"
4
+
5
+ require_relative "testing/errors"
6
+ require_relative "testing/reply"
7
+ require_relative "testing/script"
8
+ require_relative "testing/request"
9
+ require_relative "testing/observer"
10
+ require_relative "testing/adapter"
11
+ require_relative "testing/state"
12
+ require_relative "testing/checks"
13
+ require_relative "testing/stubs"
14
+ require_relative "testing/direct"
15
+ require_relative "testing/fixtures"
16
+ require_relative "testing/helpers"
17
+ require_relative "testing/minitest"
18
+ require_relative "testing/rspec"
19
+
20
+ module Squishling
21
+ # Test support, opt-in: `require "squishling/testing"` (it is not loaded with `require "squishling"`, and needs no
22
+ # test framework). A scripted adapter answers in place of a provider and records what was sent to it, plus stubs
23
+ # for the callers of squished methods, schema-driven fixtures, and assertions. See docs/testing.md and the
24
+ # Squishling::Testing::Minitest and ::RSpec mixins.
25
+ module Testing
26
+ class << self
27
+ # Makes the scripted Adapter the one every squished call goes through, and returns it. The block scripts it
28
+ # (see Script); #script adds to it later.
29
+ def install(adapter = Adapter.new)
30
+ yield adapter.script if block_given?
31
+ State.set(:observer, Observer.new(chained_observer(Squishling.config.observer)))
32
+ State.set(:adapter, adapter)
33
+ adapter
34
+ end
35
+
36
+ # Any request that reaches the provider seam raises ProviderCallError, naming the squished method. Call it in a
37
+ # suite-wide before hook: a test that forgot to script its calls then fails at the call, not far from it.
38
+ def forbid_provider_calls!
39
+ State.set(:adapter, ForbiddenAdapter.new)
40
+ end
41
+
42
+ # Runs the block with provider calls forbidden and returns the calls (the squished methods' labels) that tried
43
+ # to reach the provider, even if the block rescued the error. The adapter is put back afterwards.
44
+ def no_llm_call
45
+ config = Squishling.config
46
+ previous = config.adapter
47
+ guard = ForbiddenAdapter.new
48
+ config.adapter = guard
49
+ begin
50
+ yield
51
+ rescue ProviderCallError
52
+ nil
53
+ ensure
54
+ config.adapter = previous
55
+ end
56
+ guard.attempts
57
+ end
58
+
59
+ # See Stubs.result: Klass.call returns a real, schema-validated result of the method's type.
60
+ def stub_result(klass, attrs = nil, method: :call, squished: true, **more, &)
61
+ Stubs.result(klass, Schema.jsonify((attrs || {}).merge(more)), method:, squished:, &)
62
+ end
63
+
64
+ # See Stubs.error: Klass.call raises the real error class with realistic attributes.
65
+ def stub_error(klass, kind, **, &)
66
+ Stubs.error(klass, kind, **, &)
67
+ end
68
+
69
+ # The messages squish_validate and the schema reject this reply with. See Direct.
70
+ def validate(klass, reply, **inputs)
71
+ Direct.validate(klass, reply, **inputs)
72
+ end
73
+
74
+ def fallback_for(klass, error, **inputs)
75
+ Direct.fallback_for(klass, error, **inputs)
76
+ end
77
+
78
+ def preview(klass, **inputs)
79
+ Direct.preview(klass, **inputs)
80
+ end
81
+
82
+ def valid_reply_for(source, **overrides)
83
+ Fixtures.valid_reply_for(source, **overrides)
84
+ end
85
+
86
+ def invalid_reply_for(source, **)
87
+ Fixtures.invalid_reply_for(source, **)
88
+ end
89
+
90
+ # A recorder to assign as config.squawk (or give to with_squawk): every attempt's output, error and metadata.
91
+ def capture_squawk
92
+ SquawkCapture.new
93
+ end
94
+
95
+ # Runs the block with the capture as config.squawk, and puts the previous hook back. A squawk set on a class or
96
+ # a method still takes precedence over the configured one.
97
+ def with_squawk(capture = capture_squawk)
98
+ config = Squishling.config
99
+ previous = config.squawk
100
+ config.squawk = capture
101
+ yield capture
102
+ capture
103
+ ensure
104
+ config.squawk = previous
105
+ end
106
+
107
+ # Puts back what the helpers changed (the adapter and observer) and clears the stubs. The mixins do this
108
+ # after every test; call it yourself with a custom setup.
109
+ def reset!
110
+ Stubs.clear!
111
+ State.restore!
112
+ end
113
+
114
+ private
115
+
116
+ # An observer the app configured itself (an object, or :coolhand) is kept in the chain; the default Coolhand
117
+ # auto-detection, and our own observer from an earlier install, are not.
118
+ def chained_observer(observer)
119
+ return observer.previous if observer.is_a?(Observer)
120
+ return Squishling::Observer.resolve(observer) if observer.is_a?(Symbol)
121
+
122
+ observer if observer.respond_to?(:around)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # What Squishling.verify found: the problems in the squished classes' setup, none when it is sound.
5
+ module Verification
6
+ # One thing wrong. label names where ("Triage#call", or "adapter" for a setup that isn't one class's).
7
+ Problem = Data.define(:label, :message) do
8
+ # The existing messages already start with the label; say it once.
9
+ def to_s
10
+ message.start_with?(label) ? message : "#{label}: #{message}"
11
+ end
12
+ end
13
+
14
+ class Report
15
+ attr_reader :problems
16
+
17
+ def initialize(problems)
18
+ @problems = problems.dup.freeze
19
+ freeze
20
+ end
21
+
22
+ def ok?
23
+ problems.empty?
24
+ end
25
+
26
+ def size
27
+ problems.size
28
+ end
29
+
30
+ # A lint-style listing, one problem per line.
31
+ def to_s
32
+ return "Squishling: no problems found" if ok?
33
+
34
+ lines = problems.map { |problem| " #{problem}" }
35
+ "Squishling found #{size} #{size == 1 ? 'problem' : 'problems'}:\n#{lines.join("\n")}"
36
+ end
37
+ end
38
+ end
39
+ end