feat: add opt-in string coercion to List value matching - #48
Conversation
Add an optional `coerce` boolean to the List condition. When true, the list values and the tested input are compared as strings, so an integer-authored list matches a string caller (and vice versa). Defaults to exact-type matching, keeping existing definitions fully backward compatible; the flag is serialized only when enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff-vader-bot
left a comment
There was a problem hiding this comment.
🟢 Risk: LOW — no substantive findings
✅ Diff Vader · Approved
Council reviewed 6 files · 12 reviewers · 0 findings · $0.35
- ✅
blast‑radius - ✅
correctness - ✅
cost - ❓
github‑actions— skipped: filter: not applicable to changed files - ❓
iac— skipped: filter: not applicable to changed files - ❓
migration‑safety— skipped: filter: not applicable to changed files - ✅
performance - ✅
revertibility - ✅
security‑tenant‑isolation - ❓
supply‑chain— skipped: no external packages changed in diff - ✅
supportability - ✅
test‑adequacy
Why: All approval gates passed.
💡 /diff-vader-review <name> adds a one-shot reviewer. Beyond this PR's council, you can request: backup-correctness, loop-design-system.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in coerce flag to the EightBall::Conditions::List condition so list matching can be made type-tolerant by comparing both sides as strings, while keeping default exact-type matching and preserving backwards-compatible JSON serialization.
Changes:
- Add
coercesupport toList#satisfied?, include it in wire fields, and incorporate it into equality state. - Add specs covering default behavior, coercion behavior, JSON round-tripping, and serialization omission when not enabled.
- Bump gem version to
3.4.0and document the change inCHANGELOG.md.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/marshallers/json_spec.rb | Adds JSON serialization + round-trip coverage for coerce on list conditions. |
| spec/conditions/list_spec.rb | Adds behavioral tests for default exact matching vs coerce: true, plus equality coverage. |
| lib/eight_ball/version.rb | Bumps version from 3.3.3 to 3.4.0. |
| lib/eight_ball/conditions/list.rb | Implements coerce flag, wire serialization field, and equality participation. |
| Gemfile.lock | Updates local gem version to 3.4.0. |
| CHANGELOG.md | Documents the new opt-in coerce behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| options ||= {} | ||
|
|
||
| @values = Array(options[:values]) | ||
| @coerce = options[:coerce] || nil |
There was a problem hiding this comment.
Fixed in a70ee9c: coerce is now normalized to a canonical true/nil (options[:coerce] ? true : nil), so a non-boolean can never reach the wire. The gem's contract is still that callers pass a real boolean (the admin casts before constructing); this just guarantees the serialized form is always valid.
| def satisfied?(value) | ||
| return values.map(&:to_s).include?(value.to_s) if @coerce | ||
|
|
||
| values.include? value | ||
| end |
There was a problem hiding this comment.
Fixed in a70ee9c: satisfied? now uses values.any? { |v| v.to_s == string_value } with the tested value stringified once, so no intermediate array is allocated.
| def state | ||
| super + [@values.sort] | ||
| super + [@values.sort, @coerce] | ||
| end |
There was a problem hiding this comment.
Fixed in a70ee9c: state now sorts via sort_by(&:to_s), so a mixed-type list (which coerce makes a first-class case) no longer raises ArgumentError in ==/hash. Both operands transform identically, so equality is preserved.
- normalize coerce to a canonical true/nil so the wire never carries a stray non-boolean value - evaluate coerce allocation-free (any? instead of map(&:to_s)) - sort state by string form so a mixed-type list does not raise in ==/hash Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff-vader-bot
left a comment
There was a problem hiding this comment.
🟢 Risk: LOW — no substantive findings
✅ Diff Vader · Approved
Council reviewed 7 files · 12 reviewers · 0 findings · $0.38
- ✅
blast‑radius - ✅
correctness - ✅
cost - ❓
github‑actions— skipped: filter: not applicable to changed files - ❓
iac— skipped: filter: not applicable to changed files - ❓
migration‑safety— skipped: filter: not applicable to changed files - ✅
performance - ✅
revertibility - ✅
security‑tenant‑isolation - ❓
supply‑chain— skipped: no external packages changed in diff - ✅
supportability - ✅
test‑adequacy
Why: All approval gates passed.
💡 /diff-vader-review <name> adds a one-shot reviewer. Beyond this PR's council, you can request: backup-correctness, loop-design-system.
| end | ||
| end | ||
|
|
||
| describe '==' do |
There was a problem hiding this comment.
pr-reviewer-test-adequacy — confidence 88% (advisory)
The satisfied? method now has a fast-path for @coerce (returning early via return) and a fallback values.include? value. The tests exercise the coerce=true path thoroughly, but there is no test that verifies the exact-type path still returns true for an integer list tested against an identical integer value when coerce is nil — only the negative case (satisfied?('1') returns false) is checked. A regression that inadvertently broke the default include? path for matching values would go undetected. Consider adding: expect(EightBall::Conditions::List.new(values: [1]).satisfied?(1)).to eq true in the 'should not coerce types by default' block.
React with 👍 / 👎 / 😕 to help us calibrate — why.
There was a problem hiding this comment.
Added in 7ec9a72. The should not coerce types by default example now also asserts the positive case, List.new(values: [1]).satisfied?(1) is true, alongside the two existing negative assertions. A regression that broke the default include? path for a matching value would now fail here instead of going undetected. Suite 163/0.
kmgoswami99
left a comment
There was a problem hiding this comment.
Clean and well-scoped, and the specs cover the coerce paths. Three things worth a look before merge: coercion is pure string equality, so it isn't the numeric equivalence the doc implies (1.0 won't match [1], and nil/empty collapse together); the wire field is locked once this ships and a pre-3.4.0 reader silently falls back to exact matching; and the 'no stray value' test never actually marshals. None blocking.
| def satisfied?(value) | ||
| if @coerce | ||
| string_value = value.to_s | ||
| return values.any? { |v| v.to_s == string_value } |
There was a problem hiding this comment.
Coercion here is pure string equality (v.to_s == value.to_s), which is narrower and wider than the int/string example in the doc:
1.0won't match a[1]list ("1.0" != "1"), so it's not numeric equivalence.nil.to_sis"", so anilor empty-string caller matches a list containingnil/""(and vice versa).any_satisfied?guards nil callers but not empty strings.
Neither is exploitable (flag JSON is trusted admin config), but both are easy to trip over. Worth a doc line naming the float/nil behavior, a satisfied?(nil) test to pin it, and a call on whether empty/nil should match at all.
There was a problem hiding this comment.
Good catch, both pinned and documented in cb3d329. Added a spec asserting the exact-string semantics: satisfied?(1.0) against [1] is false (not numeric equivalence), satisfied?(nil) is false, and a [nil] list matches an empty-string caller (the nil/"" collapse). Kept the behavior as-is rather than special-casing nil/empty, since coerce targets scalar id/name lists where those are out of contract, and added a README line naming the float and nil/empty behavior.
|
|
||
| def wire_fields | ||
| %i[values parameter] | ||
| %i[values parameter coerce] |
There was a problem hiding this comment.
Two wire-contract notes now that coerce is on the wire:
The field is effectively locked once this ships. A bare boolean won't extend - the next ask (case-insensitive, numeric-tolerant) needs a second flag, where a named mode (match: "string") would've left room. Not worth reworking for v1, just flagging that the shape is committed.
And the backward-compat story has a quiet edge: a pre-3.4.0 reader ignores coerce:true and silently falls back to exact matching, so the same flag JSON evaluates differently across gem versions with no signal. The description frames older readers ignoring the field as clean; the trip-wire is that it flips the answer. Worth stating in the README compat note.
There was a problem hiding this comment.
Both flagged, thanks. On the field shape: agreed the boolean is committed once this ships; I weighed match: "string" (named mode, room to extend) against a boolean during design and went boolean for YAGNI, since the only near-term need is string coercion. A future case-insensitive/numeric mode can add its own field. Not reworking for v1, per your read.
On backward-compat: you are right that "ignored" undersells it, since it flips the result. Added a README compat note in cb3d329 stating a pre-coerce reader silently exact-matches a coerce:true list, so the same flag can evaluate differently across gem versions until every reader is upgraded.
| expect(EightBall::Conditions::List.new(values: [1, 2], coerce: true).satisfied?('3')).to eq false | ||
| end | ||
|
|
||
| it 'should normalize a truthy value to a canonical boolean so the wire carries no stray value' do |
There was a problem hiding this comment.
This test name promises the wire carries no stray value, but it only asserts the in-memory .coerce attr for coerce: 1 and coerce: false - it never marshals them. The json_spec only exercises coerce: true and the default, so the actual serialized output for a truthy non-boolean or an explicit false is never verified end-to-end. Marshal a coerce: 1 list and assert the output to make the name's claim real.
There was a problem hiding this comment.
Fixed in cb3d329. Renamed the in-memory test to drop the "wire" claim, and added end-to-end wire assertions in the marshaller spec: a coerce: 1 list marshals to "coerce":true (never "coerce":1), and an explicit coerce: false is omitted from the wire entirely. So the normalization is now verified through the serializer, not just the attr.
… the TS port - README: note coercion is exact-string (1.0 != 1, nil/"" share a form) and that a pre-coerce reader silently exact-matches (cross-version behavior flip) - pin nil / float / empty-string coerce behavior with a spec - assert the marshalled wire for a truthy non-boolean (canonical true) and an explicit false (omitted), so the normalization is verified end to end - emit `coerce` before `parameter` so the List wire matches eight-ball-ts byte for byte Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kmgoswami99
left a comment
There was a problem hiding this comment.
Fixes from the last round all landed - the string-semantics specs, the compat note, and the end-to-end marshal assertions read well. One gap left: cb3d329's byte-order invariant against the TS port isn't actually tested (see inline). Also, diff-vader's nudge to add a positive default-path assertion is already covered by the existing value accepted test at list_spec.rb:20, so no action needed there.
| coerced = [ | ||
| EightBall::Feature.new('Coerced', [EightBall::Conditions::List.new(values: [1], parameter: 'param1', coerce: true)]) | ||
| ] | ||
| expect(marshaller.marshall(coerced)).to include '"coerce":true' |
There was a problem hiding this comment.
cb3d329's whole point - emit coerce before parameter so the wire stays byte-identical with eight-ball-ts - isn't actually pinned. include '"coerce":true' passes no matter where the key lands, so reverting wire_fields to values, parameter, coerce stays green. One exact-substring assertion ("values":[1],"coerce":true,"parameter":"param1") would lock the ordering so it can't silently regress.
There was a problem hiding this comment.
Fixed in 7ec9a72. Replaced the position-agnostic include '"coerce":true' with the exact substring '"values":[1],"coerce":true,"parameter":"param1"', so the ordering is locked.
I checked it is actually load-bearing rather than just longer: reverting wire_fields back to values, parameter, coerce now fails this example (expected "...\"values\":[1],\"parameter\":\"param1\",\"coerce\":true..." to include "\"values\":[1],\"coerce\":true,\"parameter\":\"param1\""), where before it stayed green. Restored, suite 163/0.
| @coerce = options[:coerce] ? true : nil | ||
| self.parameter = options[:parameter] | ||
| end | ||
|
|
There was a problem hiding this comment.
Unrelated to coerce, but you're editing right below it: this @example doesn't run - new EightBall::Conditions::List.new [1, 'a'] has a stray new and passes a positional array where initialize expects an options hash. Should be List.new(values: [1, 'a']). Cheap to fix while you're in here.
There was a problem hiding this comment.
Fixed in 7ec9a72: the example is now EightBall::Conditions::List.new(values: [1, 'a']), dropping the stray new and the positional array. Thanks for catching it while I was in the neighbourhood.
- assert the exact marshalled substring so the coerce-before-parameter order that keeps the wire byte-identical with eight-ball-ts cannot silently regress - cover the positive exact-type match, so a break in the non-coerce path is caught - fix the List @example, which had a stray `new` and passed a positional array Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Adds an opt-in
coerceboolean to theListcondition. By defaultList#satisfied?matches by exact type (values.include?(value)), so a list authored with integers never matches a string caller and vice versa. Whencoerce: true, both the list values and the tested input are compared as strings (values.map(&:to_s).include?(value.to_s)), making the match type-tolerant.Behavior
coercedefaults to nil and matching stays exact-type, so existing definitions and consumers behave identically.coerceis serialized only whentrue(nil/false is omitted), so existing serialized blobs are untouched and older gem versions simply ignore the field.coerceparticipates in condition equality.Tests
coerce: truepersists and rebuilds enabled; a default list omits the key (never emitscoerce: false).coerce.Minor version bump 3.3.3 to 3.4.0.
🤖 Generated with Claude Code