both_is_good 0.3.0 → 0.4.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: b0a0f01162c428c642c314b4abfcef3d918315c7816fc2b3ee1d31cdf276eb18
4
- data.tar.gz: b8a4aa017737830cfe38dd8b47be2bca99d0b42258df9312c5f91dd410457ecd
3
+ metadata.gz: 51b74df537030d143e21234302934cf0893cf9cb5e476c1eaf11d7451454b986
4
+ data.tar.gz: bda32fe8c40fe83abcbb8e416f1f3e2ce4f465ec6f125b9f5a28264305df20c8
5
5
  SHA512:
6
- metadata.gz: 04f815942d66db366b44ec374ffabeba0094b1b64c41483b7a328ca4732191eae62075b2402fb4313bb7504468b5f0aac95e04f3e8029b906f4aedad618da5bd
7
- data.tar.gz: a7a640ba54f642e98a0f67c06b3745cfe47a9b75b9bc5399691001e88139a6ef4282b96d9cb6b82cdbcf52cb1811a3b708b9b21eb8a84cc2edd680a90b59f4c5
6
+ metadata.gz: a368aa4dccf34706bbb6d0ca5b563cedd9af13920580775cf44cb7c5c135439e67b0b0fb306f6c9f0e467d21c19779ebb0c2ba69be15de89aec1a050c7e10fd7
7
+ data.tar.gz: 5d682bcfd654cae15d627aa53a51c0819a73874b70c6336df0177177e75ad1478e5ecb06f17724ce8537b71f7bec15f35452ea2180fe23115b34e170093e5949
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 (unreleased)
4
+
5
+ ### Breaking changes
6
+
7
+ * `primary:` and `secondary:` are renamed to `original:` and `replacement:`
8
+ throughout the public API.
9
+ * The internal alias prefixes change from
10
+ `_bothisgood_primary_` / `_bothisgood_secondary_` to
11
+ `_bothisgood_original_` / `_bothisgood_replacement_`.
12
+
13
+ ### New features
14
+
15
+ * Added `switch:` parameter, for swapping the implementation based on
16
+ feature-flags.
17
+
18
+ ## 0.3.1
19
+
20
+ * Allow the supplied `original` / `replacement` methods to be private.
data/README.md CHANGED
@@ -1,19 +1,20 @@
1
1
  # BothIsGood
2
2
 
3
- This gem adds a module to include into classes, supplying a convenient, concise way
4
- to implement multiple versions of the same method, and _run them both_. Then you
5
- can still _use_ the old implementation, but get an alert or log message if the new
6
- version ever produces a different result.
3
+ This gem adds a module to include into classes, supplying a convenient, concise
4
+ way to implement multiple versions of the same method, and _run them both_. Then
5
+ you can still _use_ the old implementation, but get an alert or log message if
6
+ the new version ever produces a different result.
7
7
 
8
- This is not a new concept; `scientist` pioneered the approach in 2016. But scientist
9
- is moderately _heavy_, and takes significant effort to use, so I've ended up implementing
10
- lightweight dual-implementation libraries multiple times; this time I'm publishing
11
- it so I won't have to do so again later!
8
+ This is not a new concept; `scientist` pioneered the approach in 2016. But
9
+ scientist is moderately _heavy_, and takes significant effort to use, so I've
10
+ ended up implementing lightweight dual-implementation libraries multiple times;
11
+ this time I'm publishing it so I won't have to do so again later!
12
12
 
13
13
  ## Inline Invocation
14
14
 
15
- The "simplest" way to use BothIsGood is 'inline' - no configuration object, you just
16
- supply all of the needed options on the `implemented_twice` call in place.
15
+ The "simplest" way to use BothIsGood is 'inline' - no configuration object,
16
+ you just supply all of the needed options on the `implemented_twice` call in
17
+ place.
17
18
 
18
19
  ```ruby
19
20
  include BothIsGood
@@ -21,21 +22,23 @@ include BothIsGood
21
22
  def foo_one = implementation(details)
22
23
  def foo_two = more_implementation(details)
23
24
 
24
- # A minimal call. Note that with no global configuration this is not very valuable,
25
- # since if the implementations disagree, there's no hook implemented to _tell you_ that.
26
- implemented_twice(:foo, primary: :foo_one, secondary: :foo_two)
25
+ # A minimal call. Note that with no global configuration this is not very
26
+ # valuable, since if the implementations disagree, there's no hook implemented
27
+ # to _tell you_ that.
28
+ implemented_twice(:foo, original: :foo_one, replacement: :foo_two)
27
29
 
28
30
  # A complex call using all of the available options:
29
31
  implemented_twice(
30
32
  :foo,
31
- primary: :foo_one,
32
- secondary: :foo_two,
33
+ original: :foo_one,
34
+ replacement: :foo_two,
33
35
  rate: 0.01,
36
+ switch: ->(klass, name) { FeatureFlags.enabled?(:"enable_#{name}") },
34
37
  comparator: ->(val_one, val_two) { Math.abs(val_one - val_two) < 0.01 },
35
- on_mismatch: ->(val_one, val_two) { LOGGER.warn("result mismatch on Foo#foo_one vs Foo#foo_two: #{val_one} | #{val_two}") },
38
+ on_mismatch: ->(val_one, val_two) { LOGGER.warn("mismatch: #{val_one} | #{val_two}") },
36
39
  on_compare: ->(val_one, val_two) { LOGGER.warn("comparing #{val_one} to #{val_two}") },
37
- on_primary_error: ->(err, args) { LOGGER.warn("calling foo_one with #{args.to_json} produced error #{err.class.name}") },
38
- on_secondary_error: ->(err, args) { LOGGER.warn("calling foo_two with #{args.to_json} produced error #{err.class.name}") },
40
+ on_primary_error: ->(err, args) { LOGGER.warn("primary error #{err.class.name}") },
41
+ on_secondary_error: ->(err, args) { LOGGER.warn("secondary error #{err.class.name}") },
39
42
  on_hook_error: ->(err) { LOGGER.warn("OH NO! #{err.class.name}: #{err.message}") }
40
43
  )
41
44
  ```
@@ -43,48 +46,63 @@ implemented_twice(
43
46
  The method takes these parameters:
44
47
 
45
48
  * The (only) positional parameter is the name of the method it will implement.
46
- This _can_ match the 'primary' or 'secondary' name (but not both, obviously),
47
- and if it does, `implemented_twice` will alias the existing method out of the
48
- way (to `_bothisgood_primary_#{name}` or `_bothisgood_secondary_#{name}`).
49
- * The `primary:` parameter specifies a method name that will be called _and have
50
- its result used as the result of the final method_ regardless of the comparison
51
- outcome. Errors from the primary method are bubbled up as usual.
52
- * The `secondary:` parameter specifies a method name that will be called for
53
- comparison's sake (though not necessarily every time). Errors raised from the
54
- secondary method are swallowed.
55
- * The `rate:` parameter (default 1.0) specifies what fraction of the calls should
56
- bother evaluating the secondary implementation for comparison. If the
57
- implementation is costly (makes significant database calls, for example) and/or
58
- invoked frequently, you probably want a lower rate, at least in production.
59
- * The `comparator:` parameter takes a callable, and yields two arguments to it
60
- (the results of the two implementations); its result is either truthy or falsey.
49
+ This _can_ match the `original:` or `replacement:` name (but not both),
50
+ and if it does, `implemented_twice` will alias the existing method out of
51
+ the way (to `_bothisgood_original_#{name}` or
52
+ `_bothisgood_replacement_#{name}`).
53
+ * The `original:` parameter specifies a method name that will be called _and
54
+ have its result used as the return value_ regardless of the comparison
55
+ outcome. Errors from the original method are bubbled up as usual.
56
+ * The `replacement:` parameter specifies a method name that will be called for
57
+ comparison's sake (though not necessarily every time). Errors raised from
58
+ the replacement method are swallowed.
59
+ * The `rate:` parameter (default 1.0) specifies what fraction of the calls
60
+ should bother evaluating the shadow implementation for comparison. If the
61
+ implementation is costly (makes significant database calls, for example)
62
+ and/or invoked frequently, you probably want a lower rate in production.
63
+ * The `switch:` parameter takes a callable with arity 0 or 2. When it returns
64
+ a truthy value, the roles swap: replacement becomes the return value and
65
+ original becomes the shadow (called at `rate` for comparison). Arity 2
66
+ receives the target's actual class and the method name as a symbol, making
67
+ it straightforward to drive from a feature-flag system:
68
+
69
+ ```ruby
70
+ switch: ->(klass, name) { FeatureFlags.enabled?(:"enable_#{klass}_#{name}") }
71
+ ```
72
+
73
+ `switch` can be set on a shared `Configuration` object so you only have to
74
+ define it once.
75
+ * The `comparator:` parameter takes a callable, and yields two arguments to
76
+ it (the results of the two implementations); its result is truthy or falsey.
61
77
  By default, comparison is done using `==`.
62
- * The `on_mismatch:` parameter takes a callable (lamba or Proc generally). It will
63
- yield the two values being compared, but can yield additional details depending
64
- on the arity of the callable; arities 2, 3, and 4 are all supported, and will be
65
- yielded the arguments supplied, and then a Hash of the implementation method
66
- names like `{primary: :foo_one, secondary: :foo_two}`. It will be invoked any
67
- time the results of the two implementations _differ_.
68
- * The `on_compare:` parameter takes the same shaped argument, but will be fired
69
- any time both implementations are evaluated (so every time, unless `rate` is set)
78
+ * The `on_mismatch:` parameter takes a callable (lambda or Proc generally).
79
+ It supports arities 2, 3, and 4. The first two arguments are always the
80
+ _primary_ result (the one being returned) and the _secondary_ result (the
81
+ shadow). **When `switch` is active, these are `(replacement, original)`,
82
+ not `(original, replacement)`.** Arity 3 also receives a names hash like
83
+ `{primary: :foo_one, secondary: :foo_two}` reflecting the current role
84
+ assignment. Arity 4 additionally receives the call args array before the
85
+ names hash. It fires any time the results _differ_.
86
+ * The `on_compare:` parameter takes the same shaped argument, but fires any
87
+ time both implementations are evaluated (every time unless `rate` is set).
70
88
  * The `on_primary_error:` parameter takes a callable and yields 1, 2, or 3
71
- arguments to it, depending on its arity - those arguments are the StandardError
72
- instance rescued, the args supplied to the implementation (as an array,
73
- potentially with a Hash arg at the end for any kwargs), and the name of the
74
- primary method. The exception will be re-raised after handling.
75
- * The `on_secondary_error:` parameter behaves identically (yielding the secondary
76
- method name), but secondary exceptions are _not_ re-raised.
89
+ arguments: the StandardError rescued, the args supplied (as an array,
90
+ potentially with a Hash at the end for kwargs), and the name of the primary
91
+ method. The exception will be re-raised after handling. With `switch`
92
+ active, "primary" is the replacement method.
93
+ * The `on_secondary_error:` parameter behaves identically (yielding the
94
+ secondary method name), but secondary exceptions are _not_ re-raised.
95
+ With `switch` active, "secondary" is the original method.
77
96
  * The `on_hook_error:` parameter is a callable that will be yielded _one_
78
- parameter (the StandardError instance), and is invoked if an error is _raised_
79
- during one of the other hooks. None of us write bug-free code, and the callbacks
80
- supplied to `implemented_twice` are no exception. Those errors will be swallowed
81
- if `on_hook_error` is supplied (unless your hook raises the error!), but will be
82
- bubbled otherwise.
97
+ parameter (the StandardError instance), and is invoked if an error is
98
+ _raised_ during one of the other hooks. Those errors will be swallowed if
99
+ `on_hook_error` is supplied (unless your hook re-raises!), and bubbled
100
+ otherwise.
83
101
 
84
- `implemented_twice` can additionally be called with three positional parameters;
85
- the second parameter is used as the `primary` method name, and the third parameter
86
- is used as the `secondary` method name. That means that, if you use a configuration
87
- object, you can just:
102
+ `implemented_twice` can additionally be called with three positional
103
+ parameters; the second is used as the `original` method name, and the third
104
+ as `replacement`. That means that, if you use a configuration object, you can
105
+ just:
88
106
 
89
107
  ```ruby
90
108
  include BothIsGood
@@ -92,12 +110,12 @@ include BothIsGood
92
110
  def foo_one = implementation(details)
93
111
  def foo_two = more_implementation(details)
94
112
 
95
- # defines `foo`, using `foo_one` as the primary implementation and `foo_two` as secondary.
113
+ # defines `foo`, using `foo_one` as original and `foo_two` as replacement.
96
114
  implemented_twice :foo, :foo_one, :foo_two
97
115
  ```
98
116
 
99
- If it is called with _two_ positional parameters, it will use the first argument
100
- as both the final method name _and_ the primary implementation.
117
+ If called with _two_ positional parameters, the first is used as both the
118
+ final method name _and_ the original implementation.
101
119
 
102
120
  ```ruby
103
121
  include BothIsGood
@@ -105,21 +123,22 @@ include BothIsGood
105
123
  def foo = implementation(details)
106
124
  def foo_two = more_implementation(details)
107
125
 
108
- # Defines `foo`, using `foo` as the primary implementation and `foo_two` as secondary.
109
- # In the process, the original `foo` method is redefined as `_bothisgood_primary_foo`.
126
+ # Defines `foo`, using `foo` as original and `foo_two` as replacement.
127
+ # The original `foo` method is aliased to `_bothisgood_original_foo`.
110
128
  implemented_twice :foo, :foo_two
111
129
  ```
112
130
 
113
131
  ## Configuration
114
132
 
115
- All of those parameters aside from the positional, `primary`, and `secondary` ones
116
- can be configured globally, or onto a BothIsGood::Configuration object, to avoid
117
- having to supply them constantly.
133
+ All parameters aside from the positional, `original:`, and `replacement:` ones
134
+ can be configured globally, or onto a `BothIsGood::Configuration` object, to
135
+ avoid having to supply them constantly.
118
136
 
119
137
  ```ruby
120
138
  # Global configuration
121
139
  BothIsGood.configure do |config|
122
140
  config.rate = 0.5
141
+ config.switch = ->(klass, name) { FeatureFlags.enabled?(:"enable_#{name}") }
123
142
  config.on_compare = ->(a, b) { LOGGER.puts "compared!" }
124
143
  config.on_hook_error = ->(e) { LOGGER.puts "bad -.-" }
125
144
  end
@@ -127,16 +146,15 @@ end
127
146
  # Local configuration - starting values are taken from the global config
128
147
  MY_BIG_CONFIG = BothIsGood::Configuration.new
129
148
  MY_BIG_CONFIG.rate = 0.7
130
- MY_BIG_CONFIG.on_secondary_error = ->(a, b) { LOGGER.puts "No" }
149
+ MY_BIG_CONFIG.on_secondary_error = ->(e) { LOGGER.puts "No" }
131
150
 
132
151
  module MyFoo
133
152
  include BothIsGood
134
153
  self.both_is_good_configure(MY_BIG_CONFIG)
135
154
  end
136
155
 
137
-
138
- # In-class configuration - starting values are taken from the global config, or the
139
- # supplied config object if one is given.
156
+ # In-class configuration - starting values are taken from the global config,
157
+ # or the supplied config object if one is given.
140
158
  module MyBar
141
159
  include BothIsGood
142
160
  self.both_is_good_configure(rate: 0.02)
@@ -2,6 +2,7 @@ module BothIsGood
2
2
  class Configuration
3
3
  DEFAULTS = {
4
4
  rate: 1.0,
5
+ switch: nil,
5
6
  on_mismatch: nil,
6
7
  on_compare: nil,
7
8
  on_primary_error: nil,
@@ -32,6 +33,11 @@ module BothIsGood
32
33
  @rate = value
33
34
  end
34
35
 
36
+ def switch=(value)
37
+ validate_hook!(:switch, value, [0, 2])
38
+ @switch = value
39
+ end
40
+
35
41
  def on_mismatch=(value)
36
42
  validate_hook!(:on_mismatch, value, [2, 3, 4])
37
43
  @on_mismatch = value
@@ -1,8 +1,8 @@
1
1
  module BothIsGood
2
2
  class ImplementedTwice
3
- def initialize(owner, primary:, secondary:, **opts)
3
+ def initialize(owner, original:, replacement:, **opts)
4
4
  base = owner.both_is_good_configuration
5
- @local_config = LocalConfiguration.new(base, owner:, primary:, secondary:, **opts)
5
+ @local_config = LocalConfiguration.new(base, owner:, original:, replacement:, **opts)
6
6
  end
7
7
 
8
8
  def call(target, *args, **kwargs)
@@ -18,8 +18,18 @@ module BothIsGood
18
18
 
19
19
  private
20
20
 
21
- memoize def primary = @config.primary
22
- memoize def secondary = @config.secondary
21
+ memoize def switched?
22
+ if @config.switch.nil?
23
+ false
24
+ elsif @config.switch.arity == 0
25
+ @config.switch.call
26
+ else
27
+ @config.switch.call(@target.target_class, @target.method_name)
28
+ end
29
+ end
30
+
31
+ memoize def primary = switched? ? @config.replacement : @config.original
32
+ memoize def secondary = switched? ? @config.original : @config.replacement
23
33
 
24
34
  memoize def trigger? = rand < @config.rate
25
35
 
@@ -38,8 +48,8 @@ module BothIsGood
38
48
  on_secondary_success
39
49
  end
40
50
 
41
- memoize def primary_result = @target.send(@config.primary, *@args, **@kwargs)
42
- memoize def secondary_result = @target.send(@config.secondary, *@args, **@kwargs)
51
+ memoize def primary_result = @target.instance.send(primary, *@args, **@kwargs)
52
+ memoize def secondary_result = @target.instance.send(secondary, *@args, **@kwargs)
43
53
 
44
54
  def on_primary_error(error)
45
55
  hook = @config.on_primary_error
@@ -1,14 +1,14 @@
1
1
  module BothIsGood
2
2
  class LocalConfiguration < Configuration
3
- LOCAL_ATTRIBUTES = %i[primary secondary comparator].freeze
3
+ LOCAL_ATTRIBUTES = %i[original replacement comparator].freeze
4
4
 
5
5
  attr_reader(*LOCAL_ATTRIBUTES)
6
6
 
7
- def initialize(base_config, owner:, primary:, secondary:, **opts)
7
+ def initialize(base_config, owner:, original:, replacement:, **opts)
8
8
  comparator = opts.delete(:comparator)
9
9
  super(base_config, **opts)
10
- @primary = validated_method(owner, :primary, primary)
11
- @secondary = validated_method(owner, :secondary, secondary)
10
+ @original = validated_method(owner, :original, original)
11
+ @replacement = validated_method(owner, :replacement, replacement)
12
12
  @comparator = validated_comparator(comparator)
13
13
  end
14
14
 
@@ -16,7 +16,7 @@ module BothIsGood
16
16
 
17
17
  def validated_method(owner, role, name)
18
18
  raise ArgumentError, "#{role} must not be nil" if name.nil?
19
- unless owner.method_defined?(name)
19
+ unless owner.method_defined?(name) || owner.private_method_defined?(name)
20
20
  raise ArgumentError, "#{role} method #{name.inspect} is not defined on #{owner}"
21
21
  end
22
22
  name
@@ -0,0 +1,3 @@
1
+ module BothIsGood
2
+ Target = Struct.new(:instance, :method_name, :target_class)
3
+ end
@@ -1,3 +1,3 @@
1
1
  module BothIsGood
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/both_is_good.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "both_is_good/memoization"
2
+ require_relative "both_is_good/target"
2
3
 
3
4
  module BothIsGood
4
5
  def self.configuration = Configuration.global
@@ -23,13 +24,13 @@ module BothIsGood
23
24
  @both_is_good_configuration || BothIsGood.configuration
24
25
  end
25
26
 
26
- def implemented_twice(*positional, primary: nil, secondary: nil, **opts)
27
- implementer = DualImplementer.new(*positional, target: self, primary:, secondary:, **opts)
27
+ def implemented_twice(*positional, original: nil, replacement: nil, **opts)
28
+ implementer = DualImplementer.new(*positional, target: self, original:, replacement:, **opts)
28
29
  implementer.apply_aliases!
29
30
  runner = implementer.implementation
30
31
 
31
32
  define_method(implementer.name) do |*args, **kwargs|
32
- runner.call(self, *args, **kwargs)
33
+ runner.call(BothIsGood::Target.new(self, implementer.name, self.class), *args, **kwargs)
33
34
  end
34
35
  end
35
36
  end
@@ -37,11 +38,11 @@ module BothIsGood
37
38
  class DualImplementer
38
39
  include Memoization
39
40
 
40
- def initialize(*positional, target:, primary:, secondary:, **opts)
41
+ def initialize(*positional, target:, original:, replacement:, **opts)
41
42
  @target = target
42
43
  @positional = positional
43
- @kw_primary = primary
44
- @kw_secondary = secondary
44
+ @kw_original = original
45
+ @kw_replacement = replacement
45
46
  @opts = opts
46
47
 
47
48
  validate!
@@ -50,28 +51,28 @@ module BothIsGood
50
51
  attr_reader :positional, :target, :opts
51
52
 
52
53
  memoize def name = positional.first
53
- memoize def primary = aliased_primary? ? :"_bothisgood_primary_#{name}" : original_primary
54
- memoize def secondary = aliased_secondary? ? :"_bothisgood_secondary_#{name}" : original_secondary
54
+ memoize def original = aliased_original? ? :"_bothisgood_original_#{name}" : supplied_original
55
+ memoize def replacement = aliased_replacement? ? :"_bothisgood_replacement_#{name}" : supplied_replacement
55
56
 
56
57
  def apply_aliases!
57
- target.alias_method(primary, name) if aliased_primary?
58
- target.alias_method(secondary, name) if aliased_secondary?
58
+ target.alias_method(original, name) if aliased_original?
59
+ target.alias_method(replacement, name) if aliased_replacement?
59
60
  end
60
61
 
61
- memoize def implementation = ImplementedTwice.new(target, primary:, secondary:, **opts)
62
+ memoize def implementation = ImplementedTwice.new(target, original:, replacement:, **opts)
62
63
 
63
64
  private
64
65
 
65
- memoize def original_primary = @kw_primary || positional[-2]
66
- memoize def original_secondary = @kw_secondary || positional.last
66
+ memoize def supplied_original = @kw_original || positional[-2]
67
+ memoize def supplied_replacement = @kw_replacement || positional.last
67
68
 
68
- memoize def aliased_primary? = original_primary == name
69
- memoize def aliased_secondary? = original_secondary == name
69
+ memoize def aliased_original? = supplied_original == name
70
+ memoize def aliased_replacement? = supplied_replacement == name
70
71
 
71
72
  def validate!
72
73
  validate_name_supplied!
73
- validate_secondary_supplied!
74
- validate_no_primary_secondary_match!
74
+ validate_replacement_supplied!
75
+ validate_no_original_replacement_match!
75
76
  validate_no_mixing!
76
77
  validate_no_extra_positional!
77
78
  end
@@ -81,20 +82,20 @@ module BothIsGood
81
82
  raise(ArgumentError, "the 'name' positional parameter is required")
82
83
  end
83
84
 
84
- def validate_secondary_supplied!
85
- return if @kw_secondary || positional.length >= 2
86
- raise ArgumentError, "secondary is required, either as a positional argument or as a keyword argument"
85
+ def validate_replacement_supplied!
86
+ return if @kw_replacement || positional.length >= 2
87
+ raise ArgumentError, "replacement is required, either as a positional argument or as a keyword argument"
87
88
  end
88
89
 
89
- def validate_no_primary_secondary_match!
90
- return if original_primary != original_secondary
91
- raise ArgumentError, "primary and secondary cannot be the same method"
90
+ def validate_no_original_replacement_match!
91
+ return if supplied_original != supplied_replacement
92
+ raise ArgumentError, "original and replacement cannot be the same method"
92
93
  end
93
94
 
94
95
  def validate_no_mixing!
95
96
  return if positional.length <= 1
96
- return if @kw_primary.nil? && @kw_secondary.nil?
97
- raise ArgumentError, "cannot mix positional and keyword primary:/secondary:"
97
+ return if @kw_original.nil? && @kw_replacement.nil?
98
+ raise ArgumentError, "cannot mix positional and keyword original:/replacement:"
98
99
  end
99
100
 
100
101
  def validate_no_extra_positional!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: both_is_good
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Mueller
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -126,6 +126,7 @@ files:
126
126
  - ".rspec"
127
127
  - ".rubocop.yml"
128
128
  - ".standard.yml"
129
+ - CHANGELOG.md
129
130
  - LICENSE
130
131
  - README.md
131
132
  - both_is_good.gemspec
@@ -135,6 +136,7 @@ files:
135
136
  - lib/both_is_good/invocation.rb
136
137
  - lib/both_is_good/local_configuration.rb
137
138
  - lib/both_is_good/memoization.rb
139
+ - lib/both_is_good/target.rb
138
140
  - lib/both_is_good/version.rb
139
141
  homepage: https://github.com/nevinera/both_is_good
140
142
  licenses: