audition 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 +4 -4
- data/README.md +77 -33
- data/lib/audition/bundle_sweep.rb +24 -15
- data/lib/audition/cli.rb +127 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +219 -20
- data/lib/audition/dynamic/prober.rb +150 -44
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +4 -1
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +71 -0
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1050 -7
- data/lib/audition/static/literal_classifier.rb +118 -18
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +2 -0
- metadata +10 -4
data/lib/audition/report.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Audition
|
|
|
9
9
|
not_ready: "not ractor-ready",
|
|
10
10
|
blocked: "own code is ractor-ready; blocked by dependencies",
|
|
11
11
|
risky: "risky: warnings only, no hard errors",
|
|
12
|
-
ready: "ractor-ready as far as
|
|
12
|
+
ready: "ractor-ready as far as Audition can tell"
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
15
|
attr_reader :target_type, :target_root, :findings,
|
|
@@ -60,13 +60,19 @@ module Audition
|
|
|
60
60
|
counts[:dep_error].positive?
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Test findings count apart at every severity: they are the
|
|
64
|
+
# target's code, but a production boot never loads them, so
|
|
65
|
+
# they never touch the verdict.
|
|
63
66
|
def counts
|
|
64
67
|
@counts ||= begin
|
|
65
68
|
base = {error: 0, dep_error: 0, warning: 0, info: 0,
|
|
69
|
+
test_error: 0, test_warning: 0, test_info: 0,
|
|
66
70
|
fixable: 0}
|
|
67
71
|
findings.each_with_object(base) do |f, acc|
|
|
68
72
|
if f.error? && f.dependency?
|
|
69
73
|
acc[:dep_error] += 1
|
|
74
|
+
elsif f.test?
|
|
75
|
+
acc[:"test_#{f.severity}"] += 1
|
|
70
76
|
else
|
|
71
77
|
acc[f.severity] += 1
|
|
72
78
|
end
|
|
@@ -86,3 +92,4 @@ require_relative "report/style"
|
|
|
86
92
|
require_relative "report/text"
|
|
87
93
|
require_relative "report/json"
|
|
88
94
|
require_relative "report/github"
|
|
95
|
+
require_relative "report/sweep"
|
data/lib/audition/rewriters.rb
CHANGED
|
@@ -81,7 +81,10 @@ module Audition
|
|
|
81
81
|
values = collector.values
|
|
82
82
|
kinds = values.map { |v| classifier.classify(v) }
|
|
83
83
|
flagged = kinds.reject do |k|
|
|
84
|
-
|
|
84
|
+
# A magic comment cannot fix opaque values, so they
|
|
85
|
+
# must not veto one either.
|
|
86
|
+
%i[shareable unknown instance_new opaque_call
|
|
87
|
+
shallow_opaque].include?(k)
|
|
85
88
|
end
|
|
86
89
|
scv_ok = values.any? &&
|
|
87
90
|
values.all? { |value| deep_literal?(value, classifier) }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "work_split"
|
|
4
4
|
|
|
5
5
|
module Audition
|
|
6
6
|
module Static
|
|
@@ -27,6 +27,10 @@ module Audition
|
|
|
27
27
|
|
|
28
28
|
PARALLEL_THRESHOLD = 16
|
|
29
29
|
|
|
30
|
+
# Workers report in batches: a port message per file would
|
|
31
|
+
# cost more than the redraw it feeds.
|
|
32
|
+
TICK_STRIDE = 25
|
|
33
|
+
|
|
30
34
|
# Scans files across Ractors when there are enough of them to
|
|
31
35
|
# be worth the spawn cost. Checks are plain shareable classes
|
|
32
36
|
# with deeply frozen catalogs, findings copy back through
|
|
@@ -34,47 +38,112 @@ module Audition
|
|
|
34
38
|
# serial path.
|
|
35
39
|
#
|
|
36
40
|
# @param paths [Array<String>] files to analyze
|
|
37
|
-
# @param workers [Integer] Ractor count (defaults to
|
|
38
|
-
#
|
|
41
|
+
# @param workers [Integer, nil] Ractor count (defaults to
|
|
42
|
+
# {#default_workers})
|
|
39
43
|
# @param threshold [Integer] minimum file count before
|
|
40
44
|
# Ractors are used at all
|
|
45
|
+
# @param progress [Progress] ticked per file
|
|
41
46
|
# @return [Array<Finding>]
|
|
42
|
-
def analyze_paths(paths, workers:
|
|
43
|
-
threshold: PARALLEL_THRESHOLD)
|
|
47
|
+
def analyze_paths(paths, workers: nil,
|
|
48
|
+
threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT)
|
|
49
|
+
workers ||= default_workers
|
|
44
50
|
if paths.size < threshold || workers <= 1
|
|
45
|
-
return paths
|
|
51
|
+
return serial_analyze(paths, progress)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
|
-
parallel_analyze(paths, workers)
|
|
54
|
+
parallel_analyze(paths, workers, progress)
|
|
49
55
|
rescue Ractor::Error => e
|
|
50
56
|
# The silent fallback would otherwise mask a check that is
|
|
51
57
|
# itself Ractor-hostile; surface it under -w.
|
|
52
58
|
if $VERBOSE
|
|
53
|
-
warn "
|
|
59
|
+
warn "Audition: parallel scan fell back to serial: " \
|
|
54
60
|
"#{e.class}: #{e.message}"
|
|
55
61
|
end
|
|
56
|
-
|
|
62
|
+
progress.ractors = nil
|
|
63
|
+
serial_analyze(paths, progress)
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
private
|
|
60
67
|
|
|
61
|
-
def
|
|
68
|
+
def serial_analyze(paths, progress)
|
|
69
|
+
paths.flat_map do |path|
|
|
70
|
+
findings = analyze_path(path)
|
|
71
|
+
progress.tick
|
|
72
|
+
findings
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A port carries counts out of the workers while they run:
|
|
77
|
+
# the alternative is a status line frozen for the whole
|
|
78
|
+
# parallel phase, which is most of a large scan.
|
|
79
|
+
def parallel_analyze(paths, workers, progress)
|
|
62
80
|
experimental = Warning[:experimental]
|
|
63
81
|
Warning[:experimental] = false
|
|
64
|
-
slice = (paths.size / workers.to_f).ceil
|
|
65
82
|
checks = @checks
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
port = progress.enabled? ? Ractor::Port.new : nil
|
|
84
|
+
chunks = balanced_chunks(paths, workers)
|
|
85
|
+
progress.ractors = chunks.size
|
|
86
|
+
ractors = chunks.map do |chunk|
|
|
87
|
+
# The sentinel goes out through `ensure` so a worker that
|
|
88
|
+
# raises still releases the drain loop; the exception
|
|
89
|
+
# itself still surfaces from `Ractor#value`.
|
|
90
|
+
Ractor.new(chunk, checks, port) do |files, active, tap|
|
|
91
|
+
analyzer = Analyzer.new(checks: active)
|
|
92
|
+
pending = 0
|
|
93
|
+
begin
|
|
94
|
+
files.flat_map do |file|
|
|
95
|
+
findings = analyzer.analyze_path(file)
|
|
96
|
+
pending += 1
|
|
97
|
+
if tap && pending >= TICK_STRIDE
|
|
98
|
+
tap.send(pending)
|
|
99
|
+
pending = 0
|
|
100
|
+
end
|
|
101
|
+
findings
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
tap&.send(pending)
|
|
105
|
+
tap&.send(:done)
|
|
106
|
+
end
|
|
70
107
|
end
|
|
71
|
-
end
|
|
108
|
+
end
|
|
109
|
+
drain(port, ractors.size, progress) if port
|
|
110
|
+
ractors.flat_map(&:value)
|
|
72
111
|
ensure
|
|
73
112
|
Warning[:experimental] = experimental
|
|
74
113
|
end
|
|
75
114
|
|
|
115
|
+
def drain(port, workers, progress)
|
|
116
|
+
done = 0
|
|
117
|
+
while done < workers
|
|
118
|
+
message = port.receive
|
|
119
|
+
if message == :done
|
|
120
|
+
done += 1
|
|
121
|
+
else
|
|
122
|
+
progress.tick(message)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue
|
|
126
|
+
# Narration is cosmetic; a closed port ends it quietly and
|
|
127
|
+
# `Ractor#value` still reports what went wrong.
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
76
131
|
def default_workers
|
|
77
|
-
|
|
132
|
+
WorkSplit.workers
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Byte size stands in for parse cost, and the stat it costs
|
|
136
|
+
# is nothing beside the parse it schedules.
|
|
137
|
+
def balanced_chunks(paths, workers)
|
|
138
|
+
WorkSplit.chunks(
|
|
139
|
+
paths.map { |path| [path, file_size(path)] }, workers
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def file_size(path)
|
|
144
|
+
File.size(path)
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
0
|
|
78
147
|
end
|
|
79
148
|
|
|
80
149
|
def analyze_file(file)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Class-level state owned by a dependency. A gem's own source
|
|
7
|
+
# is outside the scanned tree, so the graph audit never sees
|
|
8
|
+
# the ivar and the call site is the only place left to flag.
|
|
9
|
+
# Generated type stubs in the target's tree record it: an
|
|
10
|
+
# attribute reader on a singleton class is a class-level ivar
|
|
11
|
+
# read by definition, and the generator marks those methods
|
|
12
|
+
# apart from ones that compute their answer.
|
|
13
|
+
class DependencyClassState < Base
|
|
14
|
+
explain :read,
|
|
15
|
+
severity: :warning,
|
|
16
|
+
message: "%{owner}.%{name} reads class-level state " \
|
|
17
|
+
"owned by a dependency",
|
|
18
|
+
why: "The stub for %{owner} declares %{name} as an " \
|
|
19
|
+
"attribute on its singleton class, so the call " \
|
|
20
|
+
"returns a class-level instance variable. A " \
|
|
21
|
+
"non-main Ractor raises Ractor::IsolationError " \
|
|
22
|
+
"(\"can not get unshareable values from instance " \
|
|
23
|
+
"variables of classes/modules\") unless whatever " \
|
|
24
|
+
"the value happens to be is shareable.",
|
|
25
|
+
fix: "Read it once on the main Ractor and pass the " \
|
|
26
|
+
"value in, or make the dependency's assignment " \
|
|
27
|
+
"deeply shareable before any Ractor starts."
|
|
28
|
+
|
|
29
|
+
explain :write,
|
|
30
|
+
severity: :error,
|
|
31
|
+
message: "%{owner}.%{name}= writes class-level state " \
|
|
32
|
+
"owned by a dependency",
|
|
33
|
+
why: "Assigning an attribute on %{owner}'s singleton " \
|
|
34
|
+
"class writes a class-level instance variable, " \
|
|
35
|
+
"which a non-main Ractor cannot do at all: it " \
|
|
36
|
+
"raises Ractor::IsolationError regardless of what " \
|
|
37
|
+
"the value is.",
|
|
38
|
+
fix: "Configure the dependency at boot on the main " \
|
|
39
|
+
"Ractor and leave it alone afterwards."
|
|
40
|
+
|
|
41
|
+
# The generator documents a reader it generated from an
|
|
42
|
+
# attribute; a hand-written method of the same shape gets
|
|
43
|
+
# its own prose and stays quiet.
|
|
44
|
+
STUB_READER = /Returns the value of attribute (\w+)/
|
|
45
|
+
STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/
|
|
46
|
+
STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/
|
|
47
|
+
STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/
|
|
48
|
+
STUB_COMMENT = /\A\s*#/
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
attr_reader :attributes
|
|
52
|
+
|
|
53
|
+
# Owner name => set of attribute names, kept shareable
|
|
54
|
+
# for parallel scanning.
|
|
55
|
+
def attributes=(pairs)
|
|
56
|
+
grouped = pairs.group_by(&:first)
|
|
57
|
+
.transform_values { |v| v.map(&:last).uniq }
|
|
58
|
+
@attributes = Ractor.make_shareable( # audition:disable
|
|
59
|
+
grouped
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param paths [Array<String>] scanned files; only
|
|
64
|
+
# generated type stubs carry this evidence
|
|
65
|
+
# @return [void]
|
|
66
|
+
def learn(paths)
|
|
67
|
+
pairs = []
|
|
68
|
+
paths.each do |path|
|
|
69
|
+
next unless path.end_with?(".rbi")
|
|
70
|
+
|
|
71
|
+
read_stub(path, pairs)
|
|
72
|
+
end
|
|
73
|
+
self.attributes = pairs
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Stub indentation tracks nesting exactly, so the scope
|
|
79
|
+
# stack follows it rather than parsing the file.
|
|
80
|
+
def read_stub(path, pairs)
|
|
81
|
+
scope = []
|
|
82
|
+
singleton = []
|
|
83
|
+
indents = []
|
|
84
|
+
documented = nil
|
|
85
|
+
File.foreach(path) do |raw|
|
|
86
|
+
line = raw.rstrip
|
|
87
|
+
next if line.empty?
|
|
88
|
+
|
|
89
|
+
indent = line[/\A */].size
|
|
90
|
+
while indents.any? && indent <= indents.last
|
|
91
|
+
indents.pop
|
|
92
|
+
scope.pop
|
|
93
|
+
singleton.pop
|
|
94
|
+
end
|
|
95
|
+
documented =
|
|
96
|
+
step_stub(line, scope, singleton, indents,
|
|
97
|
+
documented, pairs)
|
|
98
|
+
end
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def step_stub(line, scope, singleton, indents,
|
|
104
|
+
documented, pairs)
|
|
105
|
+
case line
|
|
106
|
+
when STUB_COMMENT
|
|
107
|
+
return STUB_READER.match(line)&.[](1) || documented
|
|
108
|
+
when STUB_SINGLETON
|
|
109
|
+
scope.push(scope.last)
|
|
110
|
+
singleton.push(true)
|
|
111
|
+
indents.push(line[/\A */].size)
|
|
112
|
+
when STUB_SCOPE
|
|
113
|
+
name = Regexp.last_match(1)
|
|
114
|
+
scope.push(scope.empty? ? name : "#{scope.last}::#{name}")
|
|
115
|
+
singleton.push(false)
|
|
116
|
+
indents.push(line[/\A */].size)
|
|
117
|
+
when STUB_DEF
|
|
118
|
+
name = Regexp.last_match(1)
|
|
119
|
+
if singleton.last && scope.last && documented == name
|
|
120
|
+
pairs << [scope.last, name]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
self.attributes = []
|
|
128
|
+
|
|
129
|
+
on :call_node do |node|
|
|
130
|
+
examine(node)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def examine(node)
|
|
136
|
+
owner = constant_name(node.receiver)
|
|
137
|
+
return unless owner
|
|
138
|
+
|
|
139
|
+
name = node.name.to_s
|
|
140
|
+
writer = name.end_with?("=")
|
|
141
|
+
attribute = writer ? name.chomp("=") : name
|
|
142
|
+
names = self.class.attributes[owner]
|
|
143
|
+
return unless names&.include?(attribute)
|
|
144
|
+
|
|
145
|
+
flag(node, writer ? :write : :read,
|
|
146
|
+
owner: owner, name: attribute)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def constant_name(node)
|
|
150
|
+
case node
|
|
151
|
+
when Prism::ConstantReadNode
|
|
152
|
+
node.name.to_s
|
|
153
|
+
when Prism::ConstantPathNode
|
|
154
|
+
node.location.slice.delete_prefix("::")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -103,6 +103,44 @@ module Audition
|
|
|
103
103
|
"or drop the default proc and fetch with a " \
|
|
104
104
|
"literal default: hash.fetch(key, [])."
|
|
105
105
|
|
|
106
|
+
explain :unshareable_instance,
|
|
107
|
+
severity: :warning,
|
|
108
|
+
message: "constant %{name} holds an unfrozen %{klass} " \
|
|
109
|
+
"instance",
|
|
110
|
+
why: "A fresh instance is unfrozen, so non-main " \
|
|
111
|
+
"Ractors raise Ractor::IsolationError reading " \
|
|
112
|
+
"it. A warning, not an error: .new can be " \
|
|
113
|
+
"overridden to return a shareable value.",
|
|
114
|
+
fix: "Freeze it when deeply immutable, wrap in " \
|
|
115
|
+
"Ractor.make_shareable, or keep a per-Ractor " \
|
|
116
|
+
"copy via Ractor.store_if_absent."
|
|
117
|
+
|
|
118
|
+
explain :shallow_opaque,
|
|
119
|
+
severity: :warning,
|
|
120
|
+
message: "constant %{name} is frozen at the top level " \
|
|
121
|
+
"only; what it holds is unproven",
|
|
122
|
+
why: "Freezing is shallow: elements and instance " \
|
|
123
|
+
"variables stay as their calls returned them, and " \
|
|
124
|
+
"unless those are deeply frozen a non-main Ractor " \
|
|
125
|
+
"reading the constant raises " \
|
|
126
|
+
"Ractor::IsolationError. The dynamic probe settles " \
|
|
127
|
+
"it when the target boots.",
|
|
128
|
+
fix: "Build the value with Ractor.make_shareable for a " \
|
|
129
|
+
"deep freeze, or silence a known-shareable value " \
|
|
130
|
+
"with a disable comment."
|
|
131
|
+
|
|
132
|
+
explain :opaque_constant,
|
|
133
|
+
severity: :warning,
|
|
134
|
+
message: "constant %{name} holds the result of " \
|
|
135
|
+
"%{method}; shareability unproven",
|
|
136
|
+
why: "Unless the call returns a deeply frozen value, " \
|
|
137
|
+
"non-main Ractors raise Ractor::IsolationError " \
|
|
138
|
+
"reading the constant. The dynamic probe settles " \
|
|
139
|
+
"it when the target boots.",
|
|
140
|
+
fix: "Freeze the result at definition time, or " \
|
|
141
|
+
"silence a known-shareable value with a disable " \
|
|
142
|
+
"comment."
|
|
143
|
+
|
|
106
144
|
explain :constant_mutation,
|
|
107
145
|
severity: :warning,
|
|
108
146
|
message: "in-place %{method} on constant %{name}",
|
|
@@ -152,6 +190,10 @@ module Audition
|
|
|
152
190
|
# finding stays, the autofix goes.
|
|
153
191
|
fix_ok = !mutated?(name) && !customized?(name)
|
|
154
192
|
kind = classifier.classify(value)
|
|
193
|
+
# A Sorbet cast returns its argument and a begin
|
|
194
|
+
# block its last statement: fixes, type names and
|
|
195
|
+
# depth checks target the value inside.
|
|
196
|
+
value = classifier.unwrap(value)
|
|
155
197
|
# Build-then-freeze: a bare `NAME.freeze` later in the
|
|
156
198
|
# same body makes the literal as good as frozen, so
|
|
157
199
|
# only provably mutable elements remain to report.
|
|
@@ -195,6 +237,20 @@ module Audition
|
|
|
195
237
|
autofix: wrappable ? wrap_make_shareable(value) : nil)
|
|
196
238
|
when :default_proc
|
|
197
239
|
flag(node, :hash_default_proc, name: name)
|
|
240
|
+
when :shallow_opaque
|
|
241
|
+
flag(node, :shallow_opaque, name: name)
|
|
242
|
+
# Build-then-freeze leaves depth unknown: stay silent.
|
|
243
|
+
when :instance_new
|
|
244
|
+
unless frozen_later?(name)
|
|
245
|
+
flag(node, :unshareable_instance, name: name,
|
|
246
|
+
klass:
|
|
247
|
+
classifier.const_name(value.receiver) || "new")
|
|
248
|
+
end
|
|
249
|
+
when :opaque_call
|
|
250
|
+
unless frozen_later?(name)
|
|
251
|
+
flag(node, :opaque_constant, name: name,
|
|
252
|
+
method: opaque_display(value))
|
|
253
|
+
end
|
|
198
254
|
end
|
|
199
255
|
end
|
|
200
256
|
|
|
@@ -313,6 +369,21 @@ module Audition
|
|
|
313
369
|
owner ? "#{owner}.#{call.name}" : "String##{call.name}"
|
|
314
370
|
end
|
|
315
371
|
|
|
372
|
+
# The receiver is arbitrary, often a chain, so the
|
|
373
|
+
# fallback names only the method.
|
|
374
|
+
def opaque_display(call)
|
|
375
|
+
owner = classifier.const_name(call.receiver)
|
|
376
|
+
method =
|
|
377
|
+
if owner
|
|
378
|
+
"#{owner}.#{call.name}"
|
|
379
|
+
elsif call.receiver
|
|
380
|
+
".#{call.name}"
|
|
381
|
+
else
|
|
382
|
+
call.name.to_s
|
|
383
|
+
end
|
|
384
|
+
"a #{method} call"
|
|
385
|
+
end
|
|
386
|
+
|
|
316
387
|
# `.freeze` binds tighter than an operator: `"a" + "b".freeze`
|
|
317
388
|
# freezes only "b", so operator calls get parentheses while
|
|
318
389
|
# literals and parenthesized or argument-free calls take
|