audition 0.2.0 → 0.2.2

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.
@@ -39,12 +39,21 @@ module Audition
39
39
  # @return [String] kebab-case check identifier
40
40
  def check_name(value = nil)
41
41
  @check_name = -value if value # audition:disable class-level-state
42
- @check_name || name.split("::").last
43
- .gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
42
+ @check_name || (name || "anonymous-check").split("::")
43
+ .last.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
44
44
  end
45
45
 
46
+ # Catalogs merge down the ancestry so a subclass of a
47
+ # concrete check inherits its handlers and messages
48
+ # instead of raising KeyError at visit time.
46
49
  def explanations
47
- @explanations || EMPTY_CATALOG
50
+ own = @explanations || EMPTY_CATALOG
51
+ parent = superclass
52
+ if parent.respond_to?(:explanations)
53
+ parent.explanations.merge(own)
54
+ else
55
+ own
56
+ end
48
57
  end
49
58
 
50
59
  # Registers a message catalog entry. Strings may contain
@@ -67,7 +76,13 @@ module Audition
67
76
  end
68
77
 
69
78
  def handlers
70
- @handlers || EMPTY_CATALOG
79
+ own = @handlers || EMPTY_CATALOG
80
+ parent = superclass
81
+ if parent.respond_to?(:handlers)
82
+ parent.handlers.merge(own)
83
+ else
84
+ own
85
+ end
71
86
  end
72
87
 
73
88
  # Methods born from define_method carry their block as a
@@ -11,8 +11,9 @@ module Audition
11
11
  class GlobalVariables < Base
12
12
  SAFE_READS = %w[
13
13
  $stdin $stdout $stderr $! $? $~ $_ $DEBUG $VERBOSE $/ $$
14
+ $-v $-d
14
15
  ].freeze
15
- SAFE_WRITES = %w[$DEBUG $VERBOSE].freeze
16
+ SAFE_WRITES = %w[$DEBUG $VERBOSE $-v $-d].freeze
16
17
  LOAD_PATH_GLOBALS = %w[$LOAD_PATH $: $LOADED_FEATURES $"].freeze
17
18
 
18
19
  explain :access,
@@ -104,7 +104,12 @@ module Audition
104
104
  end
105
105
 
106
106
  on :call_node do |node|
107
- flag_constant_mutation(node)
107
+ flag_constant_mutation(node, node.receiver, node.name.to_s)
108
+ end
109
+
110
+ on :index_operator_write_node, :index_or_write_node,
111
+ :index_and_write_node, :index_target_node do |node|
112
+ flag_constant_mutation(node, node.receiver, "[]=")
108
113
  end
109
114
 
110
115
  on :constant_path_write_node,
@@ -117,50 +122,108 @@ module Audition
117
122
  def examine(name, node, value)
118
123
  return if file.shareable_constants?
119
124
 
125
+ # A constant this file itself mutates in place is a
126
+ # deliberate accumulator; freezing it would raise at
127
+ # the mutation site (sinatra's PARAMS_CONFIG). The
128
+ # finding stays, the autofix goes.
129
+ fix_ok = !mutated?(name)
120
130
  case classifier.classify(value)
121
131
  when :mutable_string
122
132
  flag(node, :mutable_string, name: name,
123
- autofix: append_freeze(value))
133
+ autofix: fix_ok ? append_freeze(value) : nil)
124
134
  when :mutable_container
125
135
  flag(node, :mutable_container, name: name,
126
136
  type: container_type(value),
127
- autofix: wrap_make_shareable(value))
137
+ autofix: fix_ok ? wrap_make_shareable(value) : nil)
128
138
  when :shallow_freeze
129
139
  flag(node, :shallow_freeze, name: name,
130
- autofix: replace_with_make_shareable(value))
140
+ autofix:
141
+ fix_ok ? replace_with_make_shareable(value) : nil)
131
142
  when :sync_primitive
132
143
  klass = value.is_a?(Prism::CallNode) &&
133
144
  classifier.const_name(value.receiver)
134
145
  flag(node, :sync_primitive, name: name,
135
146
  klass: klass || "sync primitive")
136
147
  when :proc
148
+ # make_shareable on a proc raises when the proc
149
+ # captures locals or its self is unshareable (any
150
+ # top-level lambda); only capture-free lambdas inside
151
+ # a class or module body get the wrap.
152
+ body = proc_body(value)
153
+ wrappable = fix_ok && namespaced?(node) &&
154
+ (body.nil? ||
155
+ RactorIsolation::CaptureScanner
156
+ .scan(body).empty?)
137
157
  flag(node, :proc_constant, name: name,
138
- autofix: wrap_make_shareable(value))
158
+ autofix: wrappable ? wrap_make_shareable(value) : nil)
139
159
  when :default_proc
140
160
  flag(node, :hash_default_proc, name: name)
141
161
  end
142
162
  end
143
163
 
164
+ def mutated?(name)
165
+ bare = name.split("::").last
166
+ file.mutated_constants.any? do |mutated|
167
+ mutated == name || mutated.split("::").last == bare
168
+ end
169
+ end
170
+
171
+ # A proc value is a LambdaNode (`->`) or a CallNode
172
+ # (`proc { }`, `lambda { }`, `Proc.new { }`); the body
173
+ # lives in different places (rspec-expectations crashed
174
+ # the CallNode path).
175
+ def proc_body(value)
176
+ case value
177
+ when Prism::LambdaNode then value.body
178
+ when Prism::CallNode then value.block&.body
179
+ end
180
+ end
181
+
182
+ def namespaced?(node)
183
+ offset = node.location.start_offset
184
+ module_ranges.any? { |range| range.cover?(offset) }
185
+ end
186
+
187
+ def module_ranges
188
+ @module_ranges ||= begin
189
+ ranges = []
190
+ queue = [file.root]
191
+ until queue.empty?
192
+ current = queue.shift
193
+ queue.concat(current.child_nodes.compact)
194
+ case current
195
+ when Prism::ClassNode, Prism::ModuleNode,
196
+ Prism::SingletonClassNode
197
+ location = current.location
198
+ ranges <<
199
+ (location.start_offset..location.end_offset)
200
+ end
201
+ end
202
+ ranges
203
+ end
204
+ end
205
+
144
206
  # Mutators that read as data mutation, not as class-level
145
207
  # method names (delete/update/store would collide with
146
208
  # Active Record class methods). Screaming-case receivers
147
209
  # only: `RENDERERS << x` is a registry mutation, while
148
210
  # `User << x` is a class method call.
149
211
  MUTATORS = Ractor.make_shareable(
150
- %i[[]= << push unshift concat merge! replace]
212
+ SourceFile::CONST_MUTATORS
151
213
  )
152
214
 
153
- def flag_constant_mutation(node)
154
- return unless MUTATORS.include?(node.name)
215
+ def flag_constant_mutation(node, receiver, method)
216
+ if node.is_a?(Prism::CallNode)
217
+ return unless MUTATORS.include?(node.name)
218
+ end
155
219
 
156
- name = classifier.const_name(node.receiver)
220
+ name = classifier.const_name(receiver)
157
221
  return unless name
158
222
  return if name == "ENV"
159
223
  return unless name.split("::").last
160
224
  .match?(/\A[A-Z][A-Z0-9_]*\z/)
161
225
 
162
- flag(node, :constant_mutation, name: name,
163
- method: node.name.to_s)
226
+ flag(node, :constant_mutation, name: name, method: method)
164
227
  end
165
228
 
166
229
  def container_type(value)
@@ -202,12 +265,19 @@ module Audition
202
265
  end
203
266
  end
204
267
 
268
+ # `X = :a, :b` is an array literal without brackets; the
269
+ # slice must gain them or the wrap becomes a multi-arg
270
+ # call (mail's ATTRIBUTES).
205
271
  def wrap_make_shareable(value)
206
272
  source = value.location.slice
273
+ if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
274
+ source = "[#{source}]"
275
+ end
207
276
  Autofix.new(
208
277
  start_offset: value.location.start_offset,
209
278
  end_offset: value.location.end_offset,
210
- replacement: "Ractor.make_shareable(#{source})"
279
+ replacement: "Ractor.make_shareable(#{source})",
280
+ safety: :unsafe
211
281
  )
212
282
  end
213
283
 
@@ -218,7 +288,8 @@ module Audition
218
288
  Autofix.new(
219
289
  start_offset: call.location.start_offset,
220
290
  end_offset: call.location.end_offset,
221
- replacement: "Ractor.make_shareable(#{source})"
291
+ replacement: "Ractor.make_shareable(#{source})",
292
+ safety: :unsafe
222
293
  )
223
294
  end
224
295
  end
@@ -82,21 +82,51 @@ module Audition
82
82
 
83
83
  # Method definitions open fresh scopes; nothing inside
84
84
  # them can capture the surrounding locals.
85
+ # A def opens a fresh scope, but its receiver
86
+ # expression (`def x.foo`) evaluates in the enclosing
87
+ # one and can capture an outer local.
85
88
  def visit_def_node(node)
89
+ visit(node.receiver) if node.receiver
86
90
  end
87
91
 
88
- %i[
89
- visit_local_variable_read_node
90
- visit_local_variable_write_node
91
- visit_local_variable_operator_write_node
92
- visit_local_variable_or_write_node
93
- visit_local_variable_and_write_node
94
- visit_local_variable_target_node
95
- ].each do |method|
96
- define_method(method) do |node| # audition:disable unsafe-calls
97
- @names << node.name.to_s if node.depth > @level
98
- super(node)
99
- end
92
+ # Plain defs, not a define_method loop: the parallel
93
+ # scan calls these from worker Ractors, and a method
94
+ # born from define_method carries an un-shareable Proc
95
+ # that raises when dispatched from another Ractor.
96
+ def visit_local_variable_read_node(node)
97
+ note(node)
98
+ super
99
+ end
100
+
101
+ def visit_local_variable_write_node(node)
102
+ note(node)
103
+ super
104
+ end
105
+
106
+ def visit_local_variable_operator_write_node(node)
107
+ note(node)
108
+ super
109
+ end
110
+
111
+ def visit_local_variable_or_write_node(node)
112
+ note(node)
113
+ super
114
+ end
115
+
116
+ def visit_local_variable_and_write_node(node)
117
+ note(node)
118
+ super
119
+ end
120
+
121
+ def visit_local_variable_target_node(node)
122
+ note(node)
123
+ super
124
+ end
125
+
126
+ private
127
+
128
+ def note(node)
129
+ @names << node.name.to_s if node.depth > @level
100
130
  end
101
131
  end
102
132
  end
@@ -160,7 +160,7 @@ module Audition
160
160
  when Prism::ConstantReadNode
161
161
  receiver.name.to_s == expected
162
162
  when Prism::ConstantPathNode
163
- receiver.location.slice == expected
163
+ receiver.location.slice.delete_prefix("::") == expected
164
164
  else
165
165
  false
166
166
  end
@@ -48,6 +48,18 @@ module Audition
48
48
  "hook. If the value genuinely must be computed at " \
49
49
  "runtime, proxy the write to the main Ractor or use " \
50
50
  "Ractor.store_if_absent."
51
+ BEST_EFFORT_WHY =
52
+ "Writes wrap their value in Ractor.make_shareable with " \
53
+ "a rescue fallback: shareable values are deeply frozen " \
54
+ "and readable from any Ractor, while unshareable values " \
55
+ "keep their old (Ractor-hostile) behavior. Whether this " \
56
+ "state is actually safe depends on what the application " \
57
+ "assigns; the dynamic probe reports ground truth."
58
+ BEST_EFFORT_FIX =
59
+ "Assign only shareable values (strings, symbols, frozen " \
60
+ "containers) before spawning Ractors. Configuration " \
61
+ "that cannot be shareable needs per-Ractor state or a " \
62
+ "main-Ractor proxy instead."
51
63
 
52
64
  # @param sources [Hash{String => String}] path => source
53
65
  # @return [Array<Finding>]
@@ -111,9 +123,10 @@ module Audition
111
123
 
112
124
  variable = decl.name.split("#").last
113
125
  owner = display_owner(decl.owner)
114
- frozen = @frozen_memos["#{owner}/#{variable}"] == :frozen
126
+ verdict = @frozen_memos["#{owner}/#{variable}"]
115
127
  each_local_definition(decl).map do |defn|
116
- if frozen
128
+ case verdict
129
+ when :frozen
117
130
  finding_at(
118
131
  defn,
119
132
  check: "class-level-state",
@@ -123,6 +136,16 @@ module Audition
123
136
  why: FROZEN_MEMO_WHY,
124
137
  fix: FROZEN_MEMO_FIX
125
138
  )
139
+ when :best_effort
140
+ finding_at(
141
+ defn,
142
+ check: "class-level-state",
143
+ severity: :warning,
144
+ message: "best-effort frozen state #{variable} " \
145
+ "on #{owner}",
146
+ why: BEST_EFFORT_WHY,
147
+ fix: BEST_EFFORT_FIX
148
+ )
126
149
  else
127
150
  finding_at(
128
151
  defn,
@@ -179,15 +202,115 @@ module Audition
179
202
  )
180
203
  collector.groups.each do |(namespace, name), ops|
181
204
  key = "#{namespace}/#{name}"
182
- verdict =
183
- group_frozen?(ops, classifier) ? :frozen : :dirty
184
- map[key] =
185
- (map[key] == :dirty) ? :dirty : verdict
205
+ verdict = group_verdict(ops, classifier)
206
+ map[key] = weaker_verdict(map[key], verdict)
186
207
  end
208
+ mark_singleton_reopenings(file, map)
187
209
  end
188
210
  map
189
211
  end
190
212
 
213
+ # `class << Foo` bodies write ivars on Foo's singleton
214
+ # class outside the collector's `class << self` tracking;
215
+ # any such write taints the group so a reopening in one
216
+ # file can never be shadowed by a clean memo in another.
217
+ def mark_singleton_reopenings(file, map)
218
+ queue = [file.root]
219
+ until queue.empty?
220
+ node = queue.shift
221
+ queue.concat(node.child_nodes.compact)
222
+ next unless node.is_a?(Prism::SingletonClassNode)
223
+
224
+ owner =
225
+ case node.expression
226
+ when Prism::ConstantReadNode
227
+ node.expression.name.to_s
228
+ when Prism::ConstantPathNode
229
+ node.expression.location.slice
230
+ end
231
+ next unless owner
232
+
233
+ ivar_names_in(node).each do |ivar|
234
+ map["#{owner}/#{ivar}"] = :dirty
235
+ end
236
+ end
237
+ end
238
+
239
+ IVAR_NODES = [
240
+ Prism::InstanceVariableReadNode,
241
+ Prism::InstanceVariableWriteNode,
242
+ Prism::InstanceVariableOrWriteNode,
243
+ Prism::InstanceVariableOperatorWriteNode,
244
+ Prism::InstanceVariableAndWriteNode,
245
+ Prism::InstanceVariableTargetNode
246
+ ].freeze
247
+
248
+ def ivar_names_in(node)
249
+ names = []
250
+ queue = [node]
251
+ until queue.empty?
252
+ current = queue.shift
253
+ queue.concat(current.child_nodes.compact)
254
+ if IVAR_NODES.any? { |type| current.is_a?(type) }
255
+ names << current.name.to_s
256
+ end
257
+ end
258
+ names.uniq
259
+ end
260
+
261
+ # Cross-file merge keeps the weakest promise: any dirty file
262
+ # taints the group, and best-effort beats fully frozen.
263
+ VERDICT_RANK = {dirty: 0, best_effort: 1, frozen: 2}.freeze
264
+
265
+ def weaker_verdict(existing, verdict)
266
+ return verdict unless existing
267
+
268
+ [existing, verdict].min_by { |v| VERDICT_RANK[v] }
269
+ end
270
+
271
+ def group_verdict(ops, classifier)
272
+ return :dirty if ops.any? { |op| op[:body] }
273
+ return :dirty if ops.any? { |op| op[:kind] == :other }
274
+
275
+ memos = Rewriters::Memoization.memo_sites(ops)
276
+ if memos.any?
277
+ return group_frozen?(ops, classifier) ? :frozen : :dirty
278
+ end
279
+
280
+ writes = ops.select { |op| op[:kind] == :write }
281
+ return :dirty if writes.empty?
282
+
283
+ all_safe = writes.all? do |w|
284
+ value = w[:node].value
285
+ best_effort_value?(value) ||
286
+ Rewriters::Memoization.frozen_call?(value) ||
287
+ classifier.classify(value) == :shareable
288
+ end
289
+ wrapped = writes.any? do |w|
290
+ best_effort_value?(w[:node].value)
291
+ end
292
+ (all_safe && wrapped) ? :best_effort : :dirty
293
+ end
294
+
295
+ # Matches the emitted setter recipe:
296
+ # (Ractor.make_shareable(value) rescue value)
297
+ def best_effort_value?(node)
298
+ inner = node
299
+ if inner.is_a?(Prism::ParenthesesNode)
300
+ body = inner.body&.body
301
+ return false unless body && body.size == 1
302
+
303
+ inner = body[0]
304
+ end
305
+ return false unless inner.is_a?(Prism::RescueModifierNode)
306
+
307
+ call = inner.expression
308
+ call.is_a?(Prism::CallNode) &&
309
+ call.name == :make_shareable &&
310
+ call.receiver.is_a?(Prism::ConstantReadNode) &&
311
+ call.receiver.name == :Ractor
312
+ end
313
+
191
314
  def group_frozen?(ops, classifier)
192
315
  return false if ops.any? { |op| op[:body] }
193
316
  return false if ops.any? { |op| op[:kind] == :other }
@@ -203,14 +326,35 @@ module Audition
203
326
 
204
327
  memos.all? do |memo|
205
328
  value = memo[:op][:node].value
206
- Rewriters::Memoization.frozen_call?(value) ||
207
- classifier.classify(value) == :shareable
329
+ frozen_memo_value?(value, classifier)
330
+ end
331
+ end
332
+
333
+ # A bare `.freeze` on a container literal is shallow: the
334
+ # elements stay mutable and the cross-Ractor read still
335
+ # raises, so it must not count as frozen. A `.freeze` on a
336
+ # call result is accepted as the memo recipe (the dynamic
337
+ # probe verifies the value); provably shareable values pass.
338
+ def frozen_memo_value?(value, classifier)
339
+ return true if classifier.classify(value) == :shareable
340
+ return false unless Rewriters::Memoization.frozen_call?(value)
341
+
342
+ case value.receiver
343
+ when Prism::ArrayNode, Prism::HashNode,
344
+ Prism::KeywordHashNode
345
+ false
346
+ else
347
+ true
208
348
  end
209
349
  end
210
350
 
211
351
  # "Payments::<Payments>" reads as noise; show "Payments".
352
+ # Nested singleton owners produce nested angle brackets, so
353
+ # the strip repeats until the tail is gone.
212
354
  def display_owner(owner)
213
- (owner&.name || "?").sub(/::<[^>]+>\z/, "")
355
+ name = owner&.name || "?"
356
+ name = name.sub(/::<.*>\z/m, "") while name.match?(/::<.*>\z/m)
357
+ name
214
358
  end
215
359
 
216
360
  def path_from_uri(uri)
@@ -64,10 +64,14 @@ module Audition
64
64
  end
65
65
  end
66
66
 
67
+ # `::Mutex` and `Mutex` are the same constant for matching
68
+ # purposes; the leading colons are stripped.
67
69
  def const_name(node)
68
70
  case node
69
- when Prism::ConstantReadNode then node.name.to_s
70
- when Prism::ConstantPathNode then node.location.slice
71
+ when Prism::ConstantReadNode
72
+ node.name.to_s
73
+ when Prism::ConstantPathNode
74
+ node.location.slice.delete_prefix("::")
71
75
  end
72
76
  end
73
77
 
@@ -96,6 +100,7 @@ module Audition
96
100
  name = const_name(receiver)
97
101
  return :sync_primitive if SYNC_PRIMITIVES.include?(name)
98
102
  return :shareable if SHAREABLE_FACTORIES.include?(name)
103
+ return :proc if name == "Proc" && node.block
99
104
  # Hash.new retains its block as the default proc;
100
105
  # Array.new only uses its block to build elements.
101
106
  return :default_proc if name == "Hash" && node.block
@@ -25,11 +25,22 @@ module Audition
25
25
 
26
26
  def root = parse_result.value
27
27
 
28
+ # Ruby matches magic-comment keys and values
29
+ # case-insensitively and ignores magic comments that appear
30
+ # after the first statement (verified on 4.0); Prism reports
31
+ # them all, so both rules are applied here.
28
32
  def magic_comment(key)
33
+ limit = first_statement_offset
29
34
  comment = parse_result.magic_comments.find do |mc|
30
- mc.key_loc.slice == key
35
+ mc.key_loc.slice.downcase == key &&
36
+ mc.key_loc.start_offset < limit
31
37
  end
32
- comment&.value_loc&.slice
38
+ comment&.value_loc&.slice&.downcase
39
+ end
40
+
41
+ def first_statement_offset
42
+ first = root.statements.body.first
43
+ first ? first.location.start_offset : source.bytesize
33
44
  end
34
45
 
35
46
  # String literals in this file are frozen (and therefore
@@ -48,7 +59,9 @@ module Audition
48
59
  end
49
60
 
50
61
  def line_at(number)
51
- @lines ||= source.lines
62
+ @lines ||= source.lines.map do |line|
63
+ line.dup.force_encoding(Encoding::UTF_8).scrub
64
+ end
52
65
  @lines[number - 1]&.strip
53
66
  end
54
67
 
@@ -87,6 +100,54 @@ module Audition
87
100
  @raw ||= source.dup.force_encoding(Encoding::BINARY)
88
101
  end
89
102
 
103
+ # Method names that read as in-place data mutation when
104
+ # sent to a constant. Shared by the mutable-constants check
105
+ # and the fixers: a constant this file mutates is a
106
+ # deliberate accumulator (sinatra's PARAMS_CONFIG) and must
107
+ # never be frozen by magic comment or wrap.
108
+ CONST_MUTATORS = %i[
109
+ []= << push unshift concat merge! replace
110
+ ].freeze
111
+
112
+ # Index writes are their own node types, not calls:
113
+ # `COUNTS[k] += 1` is IndexOperatorWriteNode and
114
+ # `CACHE[k] ||= v` is IndexOrWriteNode; missing them let a
115
+ # `.freeze` autofix produce FrozenError at runtime.
116
+ INDEX_WRITES = [
117
+ Prism::IndexOperatorWriteNode,
118
+ Prism::IndexOrWriteNode,
119
+ Prism::IndexAndWriteNode,
120
+ Prism::IndexTargetNode
121
+ ].freeze
122
+
123
+ # @return [Array<String>] names of constants that receive a
124
+ # mutator call somewhere in this file
125
+ def mutated_constants
126
+ @mutated_constants ||= begin
127
+ names = []
128
+ queue = [root]
129
+ until queue.empty?
130
+ node = queue.shift
131
+ queue.concat(node.child_nodes.compact)
132
+ receiver =
133
+ if node.is_a?(Prism::CallNode) &&
134
+ CONST_MUTATORS.include?(node.name)
135
+ node.receiver
136
+ elsif INDEX_WRITES.any? { |type| node.is_a?(type) }
137
+ node.receiver
138
+ end
139
+
140
+ case receiver
141
+ when Prism::ConstantReadNode
142
+ names << receiver.name.to_s
143
+ when Prism::ConstantPathNode
144
+ names << receiver.location.slice
145
+ end
146
+ end
147
+ names.uniq
148
+ end
149
+ end
150
+
90
151
  # Keys Ruby actually honors. Prism reports every comment
91
152
  # shaped like `# key: value`, which sweeps up documentation
92
153
  # (`# I18n.t: 'date.formats.short'`); inserting after those
@@ -104,8 +165,10 @@ module Audition
104
165
  newline = raw.index("\n")
105
166
  offset = newline ? newline + 1 : raw.bytesize
106
167
  end
168
+ limit = first_statement_offset
107
169
  after_magic = parse_result.magic_comments.filter_map do |mc|
108
- next unless MAGIC_KEYS.include?(mc.key_loc.slice)
170
+ next unless MAGIC_KEYS.include?(mc.key_loc.slice.downcase)
171
+ next unless mc.key_loc.start_offset < limit
109
172
 
110
173
  newline = raw.index("\n", mc.value_loc.end_offset)
111
174
  newline ? newline + 1 : raw.bytesize
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: audition
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '0.1'
60
+ version: '1.0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '0.1'
67
+ version: '1.0'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: tty-link
70
70
  requirement: !ruby/object:Gem::Requirement