audition 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -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 +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- 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 +3 -0
- metadata +12 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "table_tennis"
|
|
5
|
+
|
|
6
|
+
module Audition
|
|
7
|
+
class Report
|
|
8
|
+
# Renders a bundle sweep. Its unit is a gem rather than a
|
|
9
|
+
# finding, so it gets its own renderer instead of bending
|
|
10
|
+
# Report around a shape it does not have.
|
|
11
|
+
class Sweep
|
|
12
|
+
CELLS = {
|
|
13
|
+
:not_ready => "not ready", :blocked => "blocked",
|
|
14
|
+
:risky => "risky", :ready => "ready", nil => "-"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# The severity glyphs the text report uses, so a verdict
|
|
18
|
+
# reads the same in a cell as it does in a summary.
|
|
19
|
+
GLYPHS = {
|
|
20
|
+
not_ready: :error, blocked: :warning,
|
|
21
|
+
risky: :warning, ready: :pass
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Foreground only, and applied to the whole row: a
|
|
25
|
+
# background fill reads as a bar across the table, and a
|
|
26
|
+
# painted cell would widen its column by the invisible
|
|
27
|
+
# length of its own escape sequence. Ready gems keep the
|
|
28
|
+
# default color, so what is colored is what needs reading.
|
|
29
|
+
PAINTS = Ractor.make_shareable({
|
|
30
|
+
:not_ready => [:red], :blocked => [:magenta],
|
|
31
|
+
:risky => [:yellow], nil => [:faint]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
TITLE = "Audition bundle sweep"
|
|
35
|
+
|
|
36
|
+
# @param rows [Array<BundleSweep::Row>] one per locked gem
|
|
37
|
+
# @param style [Style] palette for the text rendering
|
|
38
|
+
def initialize(rows, style)
|
|
39
|
+
@rows = rows
|
|
40
|
+
@style = style
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param table_opts [Hash] what the terminal supports; see
|
|
44
|
+
# the caller for why color cannot be detected here
|
|
45
|
+
# @return [String] the table plus a summary line
|
|
46
|
+
def render(**table_opts)
|
|
47
|
+
table = TableTennis.new(cells, title: TITLE, mark: paint,
|
|
48
|
+
**table_opts)
|
|
49
|
+
"#{table}\n#{summary}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def json
|
|
53
|
+
JSON.pretty_generate(
|
|
54
|
+
"audition" => VERSION,
|
|
55
|
+
"ruby" => RUBY_VERSION,
|
|
56
|
+
"bundle" => @rows.map { |r| json_row(r) }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Sweep rows carry no file or line, so annotations land on
|
|
61
|
+
# the run summary rather than a diff.
|
|
62
|
+
# @return [String] annotation lines plus a summary line
|
|
63
|
+
def annotations
|
|
64
|
+
lines = @rows.filter_map do |row|
|
|
65
|
+
level = annotation_level(row)
|
|
66
|
+
next unless level
|
|
67
|
+
|
|
68
|
+
"::#{level} title=Audition::gem #{row.name} " \
|
|
69
|
+
"#{row.version}: #{row.errors} errors, " \
|
|
70
|
+
"#{row.dep_errors} dependency errors, " \
|
|
71
|
+
"#{row.warnings} warnings (#{CELLS.fetch(row.verdict)})"
|
|
72
|
+
end
|
|
73
|
+
(lines << plain_summary).join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] job summary page table for Actions
|
|
77
|
+
def markdown
|
|
78
|
+
lines = [
|
|
79
|
+
"## #{TITLE}", "",
|
|
80
|
+
"| gem | version | verdict | errors | dep errors " \
|
|
81
|
+
"| warnings | fixable |",
|
|
82
|
+
"| --- | --- | --- | --- | --- | --- | --- |"
|
|
83
|
+
]
|
|
84
|
+
@rows.each do |r|
|
|
85
|
+
lines << "| #{r.name} | #{r.version} | " \
|
|
86
|
+
"#{CELLS.fetch(r.verdict)} | #{r.errors} | " \
|
|
87
|
+
"#{r.dep_errors} | #{r.warnings} | #{r.fixable} |"
|
|
88
|
+
end
|
|
89
|
+
lines.push("", plain_summary).join("\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def cells
|
|
95
|
+
@rows.map do |r|
|
|
96
|
+
{
|
|
97
|
+
"gem" => r.name,
|
|
98
|
+
"version" => r.version,
|
|
99
|
+
"verdict" => verdict_cell(r.verdict),
|
|
100
|
+
"errors" => clean_as_blank(r.errors),
|
|
101
|
+
"dep errors" => clean_as_blank(r.dep_errors),
|
|
102
|
+
"warnings" => clean_as_blank(r.warnings),
|
|
103
|
+
"fixable" => clean_as_blank(r.fixable),
|
|
104
|
+
"status" => r.status
|
|
105
|
+
}
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def verdict_cell(verdict)
|
|
110
|
+
cell = CELLS.fetch(verdict)
|
|
111
|
+
glyph = GLYPHS[verdict]
|
|
112
|
+
glyph ? "#{@style.glyph(glyph)} #{cell}" : cell
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A clean count is the common case across hundreds of gems;
|
|
116
|
+
# leaving it to the table's placeholder keeps the eye on the
|
|
117
|
+
# rows that carry something.
|
|
118
|
+
def clean_as_blank(count)
|
|
119
|
+
count.positive? ? count : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The table gem hands the lambda back the row it was given,
|
|
123
|
+
# which carries the gem but not the verdict symbol.
|
|
124
|
+
def paint
|
|
125
|
+
paints = @rows.to_h do |r|
|
|
126
|
+
[[r.name, r.version], PAINTS[r.verdict]]
|
|
127
|
+
end
|
|
128
|
+
->(row) { paints[[row["gem"], row["version"]]] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def summary
|
|
132
|
+
glyph, paint = if blockers.positive?
|
|
133
|
+
[:error, :red]
|
|
134
|
+
elsif ready == @rows.size
|
|
135
|
+
[:pass, :green]
|
|
136
|
+
else
|
|
137
|
+
[:warning, :yellow]
|
|
138
|
+
end
|
|
139
|
+
head = @style.public_send(paint,
|
|
140
|
+
"#{@style.glyph(glyph)} #{plain_summary}")
|
|
141
|
+
return head if blockers.zero?
|
|
142
|
+
|
|
143
|
+
"#{head} #{@style.dim("· #{blockers} not ready")}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def plain_summary
|
|
147
|
+
"#{ready} of #{@rows.size} gems ractor-ready"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ready
|
|
151
|
+
@ready ||= @rows.count { |r| r.verdict == :ready }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def blockers
|
|
155
|
+
@blockers ||= @rows.count { |r| r.verdict == :not_ready }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def annotation_level(row)
|
|
159
|
+
if row.verdict == :not_ready ||
|
|
160
|
+
(row.errors + row.dep_errors).positive?
|
|
161
|
+
"error"
|
|
162
|
+
elsif row.warnings.positive?
|
|
163
|
+
"warning"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def json_row(row)
|
|
168
|
+
{
|
|
169
|
+
"gem" => row.name,
|
|
170
|
+
"version" => row.version,
|
|
171
|
+
"verdict" => row.verdict&.to_s,
|
|
172
|
+
"errors" => row.errors,
|
|
173
|
+
"dependency_errors" => row.dep_errors,
|
|
174
|
+
"warnings" => row.warnings,
|
|
175
|
+
"infos" => row.infos,
|
|
176
|
+
"fixable" => row.fixable,
|
|
177
|
+
"status" => row.status
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
data/lib/audition/report/text.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Audition
|
|
|
21
21
|
|
|
22
22
|
def header
|
|
23
23
|
s = @style
|
|
24
|
-
title = s.bold("
|
|
24
|
+
title = s.bold("Audition #{VERSION}")
|
|
25
25
|
meta = s.dim(
|
|
26
26
|
"ruby #{RUBY_VERSION} · #{@report.target_type} at " \
|
|
27
27
|
"#{@report.target_root}"
|
|
@@ -45,8 +45,10 @@ module Audition
|
|
|
45
45
|
fix_mark = finding.fixable? ? " #{s.cyan(s.glyph(:fix))}" : ""
|
|
46
46
|
dep_mark =
|
|
47
47
|
finding.dependency? ? " #{s.dim("(dependency)")}" : ""
|
|
48
|
+
test_mark = finding.test? ? " #{s.dim("(tests)")}" : ""
|
|
48
49
|
head = " #{glyph} #{loc}#{finding.message}" \
|
|
49
|
-
"#{fix_mark}#{dep_mark}
|
|
50
|
+
"#{fix_mark}#{dep_mark}#{test_mark} " \
|
|
51
|
+
"#{s.dim(finding.check)}"
|
|
50
52
|
[head,
|
|
51
53
|
*annotation("why", finding.why),
|
|
52
54
|
*annotation("fix", finding.fix)]
|
|
@@ -113,6 +115,15 @@ module Audition
|
|
|
113
115
|
parts << s.yellow(pluralize(c[:warning], "warning"))
|
|
114
116
|
end
|
|
115
117
|
parts << s.cyan("#{c[:info]} info") if c[:info].positive?
|
|
118
|
+
test_total = c[:test_error] + c[:test_warning] +
|
|
119
|
+
c[:test_info]
|
|
120
|
+
if test_total.positive?
|
|
121
|
+
parts << s.dim(
|
|
122
|
+
pluralize(test_total, "test finding") +
|
|
123
|
+
" (#{c[:test_error]} error / " \
|
|
124
|
+
"#{c[:test_warning]} warning / #{c[:test_info]} info)"
|
|
125
|
+
)
|
|
126
|
+
end
|
|
116
127
|
if c[:fixable].positive?
|
|
117
128
|
parts << s.cyan(
|
|
118
129
|
"#{c[:fixable]} fixable #{s.glyph(:fix)} " \
|
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) }
|
|
@@ -224,6 +227,9 @@ module Audition
|
|
|
224
227
|
kinds = ops.map { |op| op[:kind] }
|
|
225
228
|
next if kinds.include?(:other)
|
|
226
229
|
next if ops.any? { |op| op[:body] }
|
|
230
|
+
# A memo written inside an on_main block already runs
|
|
231
|
+
# on the main Ractor; the audit rates the shape itself.
|
|
232
|
+
next if ops.any? { |op| op[:proxied] }
|
|
227
233
|
|
|
228
234
|
memos = memo_sites(ops)
|
|
229
235
|
if memos.empty?
|
|
@@ -617,9 +623,29 @@ module Audition
|
|
|
617
623
|
@sclass_depth = 0
|
|
618
624
|
@def_stack = []
|
|
619
625
|
@defined_depth = 0
|
|
626
|
+
@proxy_depth = 0
|
|
620
627
|
super
|
|
621
628
|
end
|
|
622
629
|
|
|
630
|
+
# A block handed to `on_main` runs on the main Ractor by
|
|
631
|
+
# construction (the read-then-proxy escape hatch), so the
|
|
632
|
+
# ivar writes inside it are recorded as proxied.
|
|
633
|
+
def visit_call_node(node)
|
|
634
|
+
block = node.block
|
|
635
|
+
unless node.name == :on_main && block.is_a?(Prism::BlockNode)
|
|
636
|
+
return super
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
visit(node.receiver) if node.receiver
|
|
640
|
+
visit(node.arguments) if node.arguments
|
|
641
|
+
@proxy_depth += 1
|
|
642
|
+
begin
|
|
643
|
+
visit(block)
|
|
644
|
+
ensure
|
|
645
|
+
@proxy_depth -= 1
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
623
649
|
def visit_class_node(node)
|
|
624
650
|
scoped(node.constant_path.location.slice, :class) do
|
|
625
651
|
super(node)
|
|
@@ -763,7 +789,8 @@ module Audition
|
|
|
763
789
|
def_id: current_def && current_def[:id],
|
|
764
790
|
def_name: current_def && current_def[:name],
|
|
765
791
|
def_node: current_def && current_def[:node],
|
|
766
|
-
class_owner: @namespace.last[:kind] == :class
|
|
792
|
+
class_owner: @namespace.last[:kind] == :class,
|
|
793
|
+
proxied: @proxy_depth.positive?
|
|
767
794
|
}
|
|
768
795
|
end
|
|
769
796
|
end
|
|
@@ -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
|