audition 0.1.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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Non-main Ractors cannot touch global variables: reads and
7
+ # writes raise Ractor::IsolationError. The exceptions below are
8
+ # ractor-local or frame-local and verified safe on Ruby 4.0.
9
+ # Regexp capture globals ($1, $&, $`, $', $+) are separate
10
+ # Prism node types and never flagged.
11
+ class GlobalVariables < Base
12
+ SAFE_READS = %w[
13
+ $stdin $stdout $stderr $! $? $~ $_ $DEBUG $VERBOSE $/ $$
14
+ ].freeze
15
+ SAFE_WRITES = %w[$DEBUG $VERBOSE].freeze
16
+ LOAD_PATH_GLOBALS = %w[$LOAD_PATH $: $LOADED_FEATURES $"].freeze
17
+
18
+ explain :access,
19
+ severity: :error,
20
+ message: "%{access} global variable %{name}",
21
+ why: "Non-main Ractors cannot access global " \
22
+ "variables; this raises Ractor::IsolationError " \
23
+ "the moment the line executes in a Ractor " \
24
+ "(verified on Ruby 4.0).",
25
+ fix: "Pass the value into the Ractor explicitly " \
26
+ "(Ractor.new(value) { |v| ... }) or over a " \
27
+ "Ractor::Port; for per-Ractor state use " \
28
+ "Ractor.current[:key]; do one-time process " \
29
+ "setup on the main Ractor before spawning."
30
+
31
+ explain :load_path,
32
+ severity: :error,
33
+ message: "%{access} global variable %{name}",
34
+ why: "The load path ($LOAD_PATH/$LOADED_FEATURES) " \
35
+ "is main-Ractor-only global state; touching " \
36
+ "it from a non-main Ractor raises " \
37
+ "Ractor::IsolationError.",
38
+ fix: "Adjust the load path on the main Ractor " \
39
+ "during boot, before any Ractors are spawned."
40
+
41
+ on :global_variable_read_node do |node|
42
+ unless SAFE_READS.include?(node.name.to_s)
43
+ gvar(node, "read of")
44
+ end
45
+ end
46
+
47
+ on :global_variable_write_node,
48
+ :global_variable_operator_write_node,
49
+ :global_variable_or_write_node,
50
+ :global_variable_and_write_node,
51
+ :global_variable_target_node do |node|
52
+ unless SAFE_WRITES.include?(node.name.to_s)
53
+ gvar(node, "write to")
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def gvar(node, access)
60
+ name = node.name.to_s
61
+ key = LOAD_PATH_GLOBALS.include?(name) ? :load_path : :access
62
+ flag(node, key, access: access, name: name)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Reading a constant whose value is not *deeply* shareable from
7
+ # a non-main Ractor raises Ractor::IsolationError.
8
+ # Classification is deliberately conservative: values we cannot
9
+ # prove mutable statically (method calls, constant refs,
10
+ # splats) are left to the dynamic probe.
11
+ class MutableConstants < Base
12
+ CONSTANT_WHY =
13
+ "Reading a constant that holds a non-shareable object " \
14
+ "from a non-main Ractor raises Ractor::IsolationError " \
15
+ "(\"can not access non-shareable objects in constant\")."
16
+
17
+ explain :mutable_string,
18
+ severity: :error,
19
+ message: "constant %{name} holds an unfrozen " \
20
+ "String literal",
21
+ why: CONSTANT_WHY,
22
+ fix: "Add `# frozen_string_literal: true` to the " \
23
+ "file, or append `.freeze`."
24
+
25
+ explain :mutable_container,
26
+ severity: :error,
27
+ message: "constant %{name} holds a mutable %{type} " \
28
+ "literal",
29
+ why: CONSTANT_WHY,
30
+ fix: "Make it deeply shareable: " \
31
+ "`# shareable_constant_value: literal`, or " \
32
+ "wrap in Ractor.make_shareable(...). A bare " \
33
+ "`.freeze` is not enough when elements are " \
34
+ "themselves mutable."
35
+
36
+ explain :shallow_freeze,
37
+ severity: :error,
38
+ message: "constant %{name} is frozen only at the " \
39
+ "top level",
40
+ why: "Ractor shareability is deep: the outer " \
41
+ "object is frozen but its elements are not, " \
42
+ "so a non-main Ractor still raises " \
43
+ "Ractor::IsolationError reading it (verified: " \
44
+ "[[1], [2]].freeze raises).",
45
+ fix: "Use Ractor.make_shareable(...) for a deep " \
46
+ "freeze, or `# shareable_constant_value: " \
47
+ "literal`."
48
+
49
+ explain :sync_primitive,
50
+ severity: :error,
51
+ message: "constant %{name} holds a %{klass}; sync " \
52
+ "primitives are deliberately unshareable",
53
+ why: "Mutex/Queue/ConditionVariable coordinate " \
54
+ "threads inside one Ractor and can never be " \
55
+ "shared across Ractors; any non-main Ractor " \
56
+ "touching this constant raises " \
57
+ "Ractor::IsolationError.",
58
+ fix: "Use Ractor::Port for cross-Ractor " \
59
+ "coordination, keep a per-Ractor primitive " \
60
+ "via Ractor.store_if_absent, or use " \
61
+ "Ractor-safe structures (ractor_safe, ratomic " \
62
+ "gems)."
63
+
64
+ explain :proc_constant,
65
+ severity: :error,
66
+ message: "constant %{name} holds a Proc",
67
+ why: "Procs capture their creation environment and " \
68
+ "are not shareable; a non-main Ractor reading " \
69
+ "this constant raises Ractor::IsolationError.",
70
+ fix: "Wrap in Ractor.make_shareable(->(...) { ... }); " \
71
+ "it isolates self-contained lambdas; or " \
72
+ "promote the logic to a method."
73
+
74
+ on :constant_write_node, :constant_or_write_node do |node|
75
+ examine(node.name.to_s, node, node.value)
76
+ end
77
+
78
+ on :constant_path_write_node,
79
+ :constant_path_or_write_node do |node|
80
+ examine(node.target.location.slice, node, node.value)
81
+ end
82
+
83
+ private
84
+
85
+ def examine(name, node, value)
86
+ return if file.shareable_constants?
87
+
88
+ case classifier.classify(value)
89
+ when :mutable_string
90
+ flag(node, :mutable_string, name: name,
91
+ autofix: append_freeze(value))
92
+ when :mutable_container
93
+ type = value.is_a?(Prism::HashNode) ? "Hash" : "Array"
94
+ flag(node, :mutable_container, name: name, type: type,
95
+ autofix: wrap_make_shareable(value))
96
+ when :shallow_freeze
97
+ flag(node, :shallow_freeze, name: name,
98
+ autofix: replace_with_make_shareable(value))
99
+ when :sync_primitive
100
+ flag(node, :sync_primitive, name: name,
101
+ klass: classifier.const_name(value.receiver))
102
+ when :proc
103
+ flag(node, :proc_constant, name: name,
104
+ autofix: wrap_make_shareable(value))
105
+ end
106
+ end
107
+
108
+ def classifier
109
+ @classifier ||= LiteralClassifier.new(
110
+ frozen_string_literal: file.frozen_string_literal?
111
+ )
112
+ end
113
+
114
+ def append_freeze(value)
115
+ offset = value.location.end_offset
116
+ Autofix.new(
117
+ start_offset: offset,
118
+ end_offset: offset,
119
+ replacement: ".freeze"
120
+ )
121
+ end
122
+
123
+ def wrap_make_shareable(value)
124
+ source = value.location.slice
125
+ Autofix.new(
126
+ start_offset: value.location.start_offset,
127
+ end_offset: value.location.end_offset,
128
+ replacement: "Ractor.make_shareable(#{source})"
129
+ )
130
+ end
131
+
132
+ # Replaces the whole `<literal>.freeze` call with a deep
133
+ # wrap of the literal.
134
+ def replace_with_make_shareable(call)
135
+ source = call.receiver.location.slice
136
+ Autofix.new(
137
+ start_offset: call.location.start_offset,
138
+ end_offset: call.location.end_offset,
139
+ replacement: "Ractor.make_shareable(#{source})"
140
+ )
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Ractor.new verifies block isolation at creation time: a
7
+ # block that touches locals from the enclosing scope raises
8
+ # ArgumentError before the Ractor ever runs. Prism records the
9
+ # resolution depth of every local reference, so captures are
10
+ # detectable exactly: a reference whose depth reaches past the
11
+ # Ractor block's own scope is an outer capture.
12
+ class RactorIsolation < Base
13
+ explain :outer_capture,
14
+ severity: :error,
15
+ message: "Ractor.new block captures outer local " \
16
+ "variable(s) %{names}",
17
+ why: "Block isolation is checked when Ractor.new " \
18
+ "runs: touching locals of the enclosing scope " \
19
+ "raises ArgumentError (\"can not isolate a " \
20
+ "Proc because it accesses outer variables\").",
21
+ fix: "Pass the values in as arguments, " \
22
+ "Ractor.new(x) { |x| ... }, or send them " \
23
+ "through a Ractor::Port."
24
+
25
+ def visit_call_node(node)
26
+ examine(node)
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def examine(node)
33
+ return unless node.name == :new
34
+ return unless ractor_receiver?(node.receiver)
35
+
36
+ block = node.block
37
+ return unless block.is_a?(Prism::BlockNode)
38
+ return unless block.body
39
+
40
+ names = CaptureScanner.scan(block.body)
41
+ return if names.empty?
42
+
43
+ flag(node, :outer_capture, names: names.join(", "))
44
+ end
45
+
46
+ def ractor_receiver?(receiver)
47
+ receiver.is_a?(Prism::ConstantReadNode) &&
48
+ receiver.name == :Ractor
49
+ end
50
+
51
+ # Walks the Ractor block's body tracking how many block
52
+ # scopes deep we are; a local reference with depth greater
53
+ # than that resolves outside the Ractor block.
54
+ class CaptureScanner < Prism::Visitor
55
+ def self.scan(body)
56
+ scanner = new
57
+ scanner.visit(body)
58
+ scanner.names.uniq
59
+ end
60
+
61
+ attr_reader :names
62
+
63
+ def initialize
64
+ @names = []
65
+ @level = 0
66
+ super
67
+ end
68
+
69
+ def visit_block_node(node)
70
+ @level += 1
71
+ super
72
+ ensure
73
+ @level -= 1
74
+ end
75
+
76
+ def visit_lambda_node(node)
77
+ @level += 1
78
+ super
79
+ ensure
80
+ @level -= 1
81
+ end
82
+
83
+ # Method definitions open fresh scopes; nothing inside
84
+ # them can capture the surrounding locals.
85
+ def visit_def_node(node)
86
+ end
87
+
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|
97
+ @names << node.name.to_s if node.depth > @level
98
+ super(node)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # `require` inside a non-main Ractor works on Ruby 4.0 by
7
+ # proxying the call to the main Ractor; but that serializes
8
+ # every Ractor through a single lock, and it means load-time
9
+ # side effects run mid-request. `autoload` is the same hazard
10
+ # in disguise: the require fires whenever the constant happens
11
+ # to be resolved first, possibly inside a Ractor.
12
+ class RuntimeRequire < Base
13
+ REQUIRE_METHODS = %i[require require_relative load].freeze
14
+
15
+ explain :runtime_require,
16
+ severity: :warning,
17
+ message: "%{method} at runtime (inside a method " \
18
+ "body)",
19
+ why: "On Ruby 4.0, require inside a non-main " \
20
+ "Ractor is proxied to the main Ractor: it " \
21
+ "works, but all Ractors serialize on it and " \
22
+ "load-time side effects run at an arbitrary " \
23
+ "point.",
24
+ fix: "Require eagerly at boot, before Ractors are " \
25
+ "spawned."
26
+
27
+ explain :autoload,
28
+ severity: :warning,
29
+ message: "autoload registers a deferred require",
30
+ why: "The require fires when the constant is first " \
31
+ "resolved, which may happen inside a non-main " \
32
+ "Ractor, serializing Ractors through the " \
33
+ "main-Ractor require proxy.",
34
+ fix: "Require eagerly at boot, or resolve the " \
35
+ "constant once on the main Ractor before " \
36
+ "spawning."
37
+
38
+ on :call_node do |node|
39
+ if node.receiver.nil?
40
+ if REQUIRE_METHODS.include?(node.name) && inside_def?
41
+ unless hoisted_already?(node)
42
+ flag(node, :runtime_require, method: node.name,
43
+ autofix: hoist_autofix(node))
44
+ end
45
+ elsif node.name == :autoload
46
+ flag(node, :autoload,
47
+ autofix: require_conversion_autofix(node))
48
+ end
49
+ end
50
+ end
51
+
52
+ def visit_def_node(node)
53
+ @def_depth = def_depth + 1
54
+ super
55
+ ensure
56
+ @def_depth -= 1
57
+ end
58
+
59
+ private
60
+
61
+ def def_depth = @def_depth ||= 0
62
+
63
+ def inside_def? = def_depth.positive?
64
+
65
+ def literal_feature(node)
66
+ return nil unless node.name == :require ||
67
+ node.name == :require_relative
68
+
69
+ arg = node.arguments&.arguments&.first
70
+ arg if arg.is_a?(Prism::StringNode) &&
71
+ node.arguments.arguments.size == 1
72
+ end
73
+
74
+ # A method-body require whose feature is already required at
75
+ # the top of the same file is a fast no-op; not worth a
76
+ # finding.
77
+ def hoisted_already?(node)
78
+ feature = literal_feature(node)
79
+ !feature.nil? &&
80
+ file.top_level_requires.include?(feature.unescaped)
81
+ end
82
+
83
+ # Requiring is idempotent, so inserting a boot-time
84
+ # duplicate preserves semantics exactly while removing the
85
+ # runtime serialization. `load` is excluded: it re-executes
86
+ # on every call.
87
+ def hoist_autofix(node)
88
+ feature = literal_feature(node)
89
+ return nil unless feature
90
+
91
+ insertion = file.boot_insertion
92
+ offset = insertion[:offset]
93
+ statement = "#{node.name} #{feature.location.slice}"
94
+ if insertion[:after_require]
95
+ Autofix.new(start_offset: offset, end_offset: offset,
96
+ replacement: "#{statement}\n")
97
+ else
98
+ offset += 1 if newline_at?(offset)
99
+ Autofix.new(start_offset: offset, end_offset: offset,
100
+ replacement: "#{statement}\n\n")
101
+ end
102
+ end
103
+
104
+ def newline_at?(offset)
105
+ file.source.byteslice(offset, 1) == "\n"
106
+ end
107
+
108
+ # Eager require is exactly the trade a Ractor deployment
109
+ # wants, but it changes load timing: unsafe tier.
110
+ def require_conversion_autofix(node)
111
+ args = node.arguments&.arguments
112
+ return nil unless args&.size == 2 &&
113
+ args[0].is_a?(Prism::SymbolNode) &&
114
+ args[1].is_a?(Prism::StringNode)
115
+
116
+ Autofix.new(
117
+ start_offset: node.location.start_offset,
118
+ end_offset: node.location.end_offset,
119
+ replacement: "require #{args[1].location.slice}",
120
+ safety: :unsafe
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ module Static
5
+ module Checks
6
+ # Knowledge base of APIs that are hostile (or noteworthy)
7
+ # under Ractors, expressed as catalog entries plus call-site
8
+ # matching rules. Severities reflect behavior verified on
9
+ # Ruby 4.0; several 3.x-era errors (ENV, trap,
10
+ # ObjectSpace.each_object) now work and are only
11
+ # informational.
12
+ class UnsafeCalls < Base
13
+ explain :ractor_yield_removed,
14
+ severity: :error,
15
+ message: "Ractor.%{method} was removed in Ruby 4.0",
16
+ why: "The yield/take rendezvous API was replaced " \
17
+ "by Ractor::Port in Ruby 4.0; calling it " \
18
+ "raises NoMethodError.",
19
+ fix: "Create a Ractor::Port, pass it into the " \
20
+ "Ractor, and use port.send/port.receive; " \
21
+ "collect results with Ractor#value."
22
+
23
+ explain :rails_class_state_macro,
24
+ severity: :error,
25
+ message: "%{method} stores state on the class " \
26
+ "object",
27
+ why: "These ActiveSupport macros are backed by " \
28
+ "class-level instance variables or class " \
29
+ "variables; both raise Ractor::IsolationError " \
30
+ "when written (and class variables even when " \
31
+ "read) from a non-main Ractor.",
32
+ fix: "Compute the value at boot and store it in a " \
33
+ "deeply frozen constant, or keep per-Ractor " \
34
+ "state via Ractor.current[:key] / " \
35
+ "Ractor.store_if_absent."
36
+
37
+ explain :objectspace_id2ref,
38
+ severity: :warning,
39
+ message: "ObjectSpace._id2ref cannot resolve " \
40
+ "objects across Ractors",
41
+ why: "Object IDs are only meaningful within the " \
42
+ "Ractor that created the object; _id2ref is " \
43
+ "also deprecated since Ruby 3.4.",
44
+ fix: "Pass objects (or their data) through " \
45
+ "Ractor::Port messages instead of smuggling " \
46
+ "object IDs."
47
+
48
+ explain :at_exit,
49
+ severity: :info,
50
+ message: "at_exit registers a process-global hook",
51
+ why: "Hooks run on the main Ractor at process " \
52
+ "exit; registering them from short-lived " \
53
+ "Ractors accumulates process-global state.",
54
+ fix: "Register exit hooks once, from the main " \
55
+ "Ractor, at boot."
56
+
57
+ explain :signal_trap,
58
+ severity: :info,
59
+ message: "%{receiver}trap installs a process-global " \
60
+ "signal handler",
61
+ why: "Works on Ruby 4.0 even from a Ractor, but " \
62
+ "the handler is process-wide; Ractors " \
63
+ "installing competing handlers race.",
64
+ fix: "Install signal handlers once, on the main " \
65
+ "Ractor."
66
+
67
+ explain :env_write,
68
+ severity: :info,
69
+ message: "ENV mutation is process-global",
70
+ why: "Works on Ruby 4.0 (ENV access from Ractors " \
71
+ "no longer raises), but the environment is " \
72
+ "shared mutable state; concurrent Ractors " \
73
+ "race on it.",
74
+ fix: "Read configuration into frozen constants at " \
75
+ "boot instead of mutating ENV at runtime."
76
+
77
+ explain :fork,
78
+ severity: :warning,
79
+ message: "%{method} from a multi-Ractor process",
80
+ why: "fork only reproduces the calling thread; " \
81
+ "other Ractors' threads vanish in the child, " \
82
+ "leaving their state inconsistent.",
83
+ fix: "Fork before spawning Ractors, or use " \
84
+ "spawn/exec."
85
+
86
+ explain :singleton_include,
87
+ severity: :warning,
88
+ message: "include Singleton memoizes the instance " \
89
+ "on the class",
90
+ why: "Singleton stores its instance in a " \
91
+ "class-level instance variable; the first " \
92
+ "access from a non-main Ractor raises " \
93
+ "Ractor::IsolationError when it tries to " \
94
+ "write it.",
95
+ fix: "Eagerly call .instance on the main Ractor " \
96
+ "at boot (freezing the instance if possible), " \
97
+ "or use Ractor.store_if_absent."
98
+
99
+ RULES = Ractor.make_shareable([
100
+ {key: :ractor_yield_removed, receiver: "Ractor",
101
+ methods: %i[yield take]},
102
+ {key: :rails_class_state_macro, receiver: nil,
103
+ methods: %i[class_attribute cattr_accessor cattr_reader
104
+ cattr_writer mattr_accessor mattr_reader
105
+ mattr_writer thread_mattr_accessor]},
106
+ {key: :objectspace_id2ref, receiver: "ObjectSpace",
107
+ methods: %i[_id2ref]},
108
+ {key: :at_exit, receiver: nil, methods: %i[at_exit]},
109
+ {key: :signal_trap, receiver: "Signal",
110
+ methods: %i[trap]},
111
+ {key: :signal_trap, receiver: nil, methods: %i[trap]},
112
+ {key: :env_write, receiver: "ENV",
113
+ methods: %i[[]= store delete update clear replace]},
114
+ {key: :fork, receiver: nil, methods: %i[fork]},
115
+ {key: :fork, receiver: "Process",
116
+ methods: %i[fork daemon]}
117
+ ])
118
+
119
+ on :call_node do |node|
120
+ apply_rules(node)
121
+ flag_singleton_include(node)
122
+ end
123
+
124
+ private
125
+
126
+ def apply_rules(node)
127
+ rule = RULES.find do |r|
128
+ r[:methods].include?(node.name) &&
129
+ receiver_matches?(r[:receiver], node.receiver)
130
+ end
131
+ return unless rule
132
+
133
+ receiver = rule[:receiver] ? "#{rule[:receiver]}." : ""
134
+ flag(node, rule[:key],
135
+ method: "#{receiver}#{node.name}",
136
+ receiver: receiver)
137
+ end
138
+
139
+ def receiver_matches?(expected, receiver)
140
+ return receiver.nil? if expected.nil?
141
+
142
+ case receiver
143
+ when Prism::ConstantReadNode
144
+ receiver.name.to_s == expected
145
+ when Prism::ConstantPathNode
146
+ receiver.location.slice == expected
147
+ else
148
+ false
149
+ end
150
+ end
151
+
152
+ def flag_singleton_include(node)
153
+ return unless node.name == :include && node.receiver.nil?
154
+
155
+ args = node.arguments&.arguments or return
156
+ singleton = args.any? do |a|
157
+ a.is_a?(Prism::ConstantReadNode) && a.name == :Singleton
158
+ end
159
+ return unless singleton
160
+
161
+ flag(node, :singleton_include)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "checks/base"
4
+ require_relative "checks/global_variables"
5
+ require_relative "checks/mutable_constants"
6
+ require_relative "checks/ractor_isolation"
7
+ require_relative "checks/runtime_require"
8
+ require_relative "checks/unsafe_calls"
9
+
10
+ module Audition
11
+ module Static
12
+ module Checks
13
+ BUILT_IN = [
14
+ GlobalVariables, MutableConstants, RactorIsolation,
15
+ RuntimeRequire, UnsafeCalls
16
+ ].freeze
17
+
18
+ # Expression-level checks, run per file. Class variables and
19
+ # class-level instance variable state are covered semantically
20
+ # by the rubydex graph audit, not by per-file visitors.
21
+ #
22
+ # @return [Array<Class>] built-in plus registered checks
23
+ def self.all
24
+ BUILT_IN + registered
25
+ end
26
+
27
+ # Extension point: gems can subclass {Base} and register here.
28
+ #
29
+ # @param check [Class] a {Base} subclass
30
+ # @return [void]
31
+ def self.register(check)
32
+ registered << check
33
+ end
34
+
35
+ # @param check [Class] a previously registered check
36
+ # @return [void]
37
+ def self.deregister(check)
38
+ registered.delete(check)
39
+ end
40
+
41
+ def self.registered
42
+ @registered ||= [] # audition:disable class-level-state
43
+ end
44
+ end
45
+ end
46
+ end