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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+
5
+ module Audition
6
+ module Static
7
+ # Whole-program semantic checks backed by the rubydex graph.
8
+ # rubydex resolves state to its true owner, so an ivar written in
9
+ # the class body, in `def self.x`, and inside `class << self`,
10
+ # across several files, unifies into one declaration owned by the
11
+ # singleton class. Per-file AST visitors cannot see that.
12
+ class GraphAudit
13
+ CVAR_WHY =
14
+ "Class variables cannot be accessed from non-main Ractors " \
15
+ "at all; both reads and writes raise " \
16
+ "Ractor::IsolationError (\"can not access class variables " \
17
+ "from non-main Ractors\")."
18
+ CVAR_FIX =
19
+ "Replace with a deeply frozen constant, per-instance " \
20
+ "state, or Ractor-local storage (Ractor.current[:key], " \
21
+ "Ractor.store_if_absent)."
22
+ STATE_WHY =
23
+ "This instance variable lives on the class/module object, " \
24
+ "which is shared across Ractors; non-main Ractors raise " \
25
+ "Ractor::IsolationError when writing it, and when reading " \
26
+ "it while it holds a non-shareable value (verified on " \
27
+ "Ruby 4.0)."
28
+ STATE_FIX =
29
+ "Precompute and freeze the value at load time, use " \
30
+ "Ractor.store_if_absent for lazy initialization, or keep " \
31
+ "per-Ractor state in Ractor.current[:key]."
32
+
33
+ # @param sources [Hash{String => String}] path => source
34
+ # @return [Array<Finding>]
35
+ def analyze_sources(sources)
36
+ graph = Rubydex::Graph.new
37
+ sources.each do |path, code|
38
+ graph.index_source(path, code, "ruby")
39
+ end
40
+ @sources = sources
41
+ audit(graph)
42
+ end
43
+
44
+ # rubydex's index_all descends directories but skips bare file
45
+ # lists, so files are fed through index_source individually.
46
+ #
47
+ # @param paths [Array<String>] files to index and audit
48
+ # @return [Array<Finding>]
49
+ def analyze_paths(paths)
50
+ sources = {}
51
+ paths.each do |path|
52
+ sources[path] = File.read(path)
53
+ rescue SystemCallError
54
+ next
55
+ end
56
+ analyze_sources(sources)
57
+ end
58
+
59
+ private
60
+
61
+ def audit(graph)
62
+ graph.resolve
63
+ findings = []
64
+ graph.declarations.each do |decl|
65
+ case decl
66
+ when Rubydex::ClassVariable
67
+ findings.concat(class_variable_findings(decl))
68
+ when Rubydex::InstanceVariable
69
+ findings.concat(class_state_findings(decl))
70
+ end
71
+ end
72
+ findings.sort_by { |f| [f.path, f.line] }
73
+ end
74
+
75
+ def class_variable_findings(decl)
76
+ variable = decl.name.split("#").last
77
+ owner = display_owner(decl.owner)
78
+ each_local_definition(decl).map do |defn|
79
+ finding_at(
80
+ defn,
81
+ check: "class-variables",
82
+ message: "class variable #{variable} on #{owner}",
83
+ why: CVAR_WHY,
84
+ fix: CVAR_FIX
85
+ )
86
+ end
87
+ end
88
+
89
+ def class_state_findings(decl)
90
+ return [] unless decl.owner.is_a?(Rubydex::SingletonClass)
91
+
92
+ variable = decl.name.split("#").last
93
+ owner = display_owner(decl.owner)
94
+ each_local_definition(decl).map do |defn|
95
+ finding_at(
96
+ defn,
97
+ check: "class-level-state",
98
+ message: "class-level instance variable #{variable} " \
99
+ "on #{owner}",
100
+ why: STATE_WHY,
101
+ fix: STATE_FIX
102
+ )
103
+ end
104
+ end
105
+
106
+ def each_local_definition(decl)
107
+ decl.definitions.reject do |defn|
108
+ defn.location.uri.start_with?("rubydex:")
109
+ end
110
+ end
111
+
112
+ def finding_at(defn, check:, message:, why:, fix:)
113
+ path = path_from_uri(defn.location.uri)
114
+ line = defn.location.start_line + 1
115
+ Finding.new(
116
+ check: check,
117
+ severity: :error,
118
+ message: message,
119
+ why: why,
120
+ fix: fix,
121
+ path: path,
122
+ line: line,
123
+ source: source_line(path, line)
124
+ )
125
+ end
126
+
127
+ # "Payments::<Payments>" reads as noise; show "Payments".
128
+ def display_owner(owner)
129
+ (owner&.name || "?").sub(/::<[^>]+>\z/, "")
130
+ end
131
+
132
+ def path_from_uri(uri)
133
+ uri.delete_prefix("file://")
134
+ end
135
+
136
+ def source_line(path, line)
137
+ content = @sources[path]
138
+ content ||= File.read(path) if File.file?(path)
139
+ content&.lines&.[](line - 1)&.strip
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Audition
6
+ module Static
7
+ # Classifies a Prism expression node by Ractor shareability:
8
+ # :shareable proven deeply shareable
9
+ # :mutable_string unfrozen String literal
10
+ # :mutable_container Array/Hash literal
11
+ # :shallow_freeze frozen container with mutable elements
12
+ # :sync_primitive Mutex/Queue/... constructor
13
+ # :proc lambda or proc
14
+ # :unknown cannot tell statically
15
+ class LiteralClassifier
16
+ SYNC_PRIMITIVES = %w[
17
+ Mutex Monitor Queue SizedQueue ConditionVariable
18
+ Thread::Mutex Thread::Queue Thread::SizedQueue
19
+ Thread::ConditionVariable
20
+ ].freeze
21
+ SHAREABLE_FACTORIES = %w[Struct Class Module].freeze
22
+
23
+ # @param frozen_string_literal [Boolean] whether the file has
24
+ # the frozen_string_literal magic comment
25
+ def initialize(frozen_string_literal:)
26
+ @frozen_string_literal = frozen_string_literal
27
+ end
28
+
29
+ # @param node [Prism::Node] an expression node
30
+ # @return [Symbol] classification, see class docs
31
+ def classify(node)
32
+ case node
33
+ when Prism::IntegerNode, Prism::FloatNode,
34
+ Prism::RationalNode, Prism::ImaginaryNode,
35
+ Prism::SymbolNode, Prism::InterpolatedSymbolNode,
36
+ Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
37
+ Prism::RegularExpressionNode,
38
+ Prism::InterpolatedRegularExpressionNode
39
+ :shareable
40
+ when Prism::StringNode
41
+ @frozen_string_literal ? :shareable : :mutable_string
42
+ when Prism::InterpolatedStringNode
43
+ classify_interpolated_string(node)
44
+ when Prism::ArrayNode, Prism::HashNode,
45
+ Prism::KeywordHashNode
46
+ :mutable_container
47
+ when Prism::RangeNode
48
+ ends = [node.left, node.right].compact
49
+ if ends.all? { |n| classify(n) == :shareable }
50
+ :shareable
51
+ else
52
+ :unknown
53
+ end
54
+ when Prism::LambdaNode
55
+ :proc
56
+ when Prism::CallNode
57
+ classify_call(node)
58
+ else
59
+ :unknown
60
+ end
61
+ end
62
+
63
+ def const_name(node)
64
+ case node
65
+ when Prism::ConstantReadNode then node.name.to_s
66
+ when Prism::ConstantPathNode then node.location.slice
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Adjacent literals ("a" "b") parse as interpolation but
73
+ # compile to one static string, frozen under the magic
74
+ # comment; real interpolation stays mutable.
75
+ def classify_interpolated_string(node)
76
+ static = node.parts.all? do |part|
77
+ part.is_a?(Prism::StringNode)
78
+ end
79
+ if static && @frozen_string_literal
80
+ :shareable
81
+ else
82
+ :mutable_string
83
+ end
84
+ end
85
+
86
+ def classify_call(node)
87
+ receiver = node.receiver
88
+ case node.name
89
+ when :freeze
90
+ classify_freeze(node, receiver)
91
+ when :new
92
+ name = const_name(receiver)
93
+ return :sync_primitive if SYNC_PRIMITIVES.include?(name)
94
+ return :shareable if SHAREABLE_FACTORIES.include?(name)
95
+
96
+ :unknown
97
+ when :define
98
+ (const_name(receiver) == "Data") ? :shareable : :unknown
99
+ when :make_shareable
100
+ (const_name(receiver) == "Ractor") ? :shareable : :unknown
101
+ when :lambda, :proc
102
+ (receiver.nil? && node.block) ? :proc : :unknown
103
+ else
104
+ :unknown
105
+ end
106
+ end
107
+
108
+ def classify_freeze(node, receiver)
109
+ return :unknown unless node.arguments.nil? && receiver
110
+
111
+ case receiver
112
+ when Prism::StringNode
113
+ :shareable
114
+ when Prism::ArrayNode, Prism::HashNode
115
+ deep_classify(receiver.elements)
116
+ else
117
+ :unknown
118
+ end
119
+ end
120
+
121
+ # Fold element classifications: everything provably shareable
122
+ # gives :shareable; anything provably mutable gives
123
+ # :shallow_freeze; anything unknowable gives :unknown (stay
124
+ # silent rather than guess).
125
+ def deep_classify(elements)
126
+ verdict = :shareable
127
+ elements.each do |element|
128
+ children =
129
+ case element
130
+ when Prism::AssocNode then [element.key, element.value]
131
+ else [element]
132
+ end
133
+ children.each do |child|
134
+ case classify(child)
135
+ when :shareable then nil
136
+ when :unknown then return :unknown
137
+ else verdict = :shallow_freeze
138
+ end
139
+ end
140
+ end
141
+ verdict
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Audition
6
+ module Static
7
+ # A parsed Ruby file plus the magic comments that change Ractor
8
+ # semantics.
9
+ class SourceFile
10
+ attr_reader :path, :source, :parse_result
11
+
12
+ def self.read(path)
13
+ new(source: File.read(path), path: path)
14
+ end
15
+
16
+ def initialize(source:, path:)
17
+ @source = source
18
+ @path = path
19
+ @parse_result = Prism.parse(source)
20
+ end
21
+
22
+ def valid_syntax? = parse_result.success?
23
+
24
+ def syntax_errors = parse_result.errors
25
+
26
+ def root = parse_result.value
27
+
28
+ def magic_comment(key)
29
+ comment = parse_result.magic_comments.find do |mc|
30
+ mc.key_loc.slice == key
31
+ end
32
+ comment&.value_loc&.slice
33
+ end
34
+
35
+ # String literals in this file are frozen (and therefore
36
+ # shareable when they contain no interpolation).
37
+ def frozen_string_literal?
38
+ magic_comment("frozen_string_literal") == "true"
39
+ end
40
+
41
+ # `# shareable_constant_value: literal|experimental_everything|
42
+ # experimental_copy` makes constant values deeply frozen and
43
+ # shareable at parse time, so constant checks are moot.
44
+ # (v1 treats the comment as file-wide.)
45
+ def shareable_constants?
46
+ value = magic_comment("shareable_constant_value")
47
+ !value.nil? && value != "none"
48
+ end
49
+
50
+ def line_at(number)
51
+ @lines ||= source.lines
52
+ @lines[number - 1]&.strip
53
+ end
54
+
55
+ # Literal feature strings required by top-level statements.
56
+ def top_level_requires
57
+ @top_level_requires ||= root.statements.body.filter_map do |s|
58
+ next unless s.is_a?(Prism::CallNode) && s.receiver.nil?
59
+ next unless %i[require require_relative].include?(s.name)
60
+
61
+ arg = s.arguments&.arguments&.first
62
+ arg.unescaped if arg.is_a?(Prism::StringNode)
63
+ end
64
+ end
65
+
66
+ # Where a hoisted `require` line can be inserted: right after
67
+ # the last existing top-level require, or after the leading
68
+ # comment block (shebang and magic comments), or at the top.
69
+ def boot_insertion
70
+ last_require = root.statements.body.rfind do |s|
71
+ s.is_a?(Prism::CallNode) && s.receiver.nil? &&
72
+ %i[require require_relative].include?(s.name)
73
+ end
74
+ if last_require
75
+ newline = source.index("\n", last_require.location.end_offset)
76
+ offset = newline ? newline + 1 : source.bytesize
77
+ {offset: offset, after_require: true}
78
+ else
79
+ {offset: leading_comments_end, after_require: false}
80
+ end
81
+ end
82
+
83
+ # Where a new magic comment can go: after the shebang and any
84
+ # existing magic comments, before code.
85
+ def magic_insertion_offset
86
+ offset = 0
87
+ if source.start_with?("#!")
88
+ newline = source.index("\n")
89
+ offset = newline ? newline + 1 : source.bytesize
90
+ end
91
+ after_magic = parse_result.magic_comments.map do |mc|
92
+ newline = source.index("\n", mc.value_loc.end_offset)
93
+ newline ? newline + 1 : source.bytesize
94
+ end.max
95
+ [offset, after_magic || 0].max
96
+ end
97
+
98
+ def leading_comments_end
99
+ offset = 0
100
+ source.each_line do |line|
101
+ break unless line.start_with?("#")
102
+
103
+ offset += line.bytesize
104
+ end
105
+ offset
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ # Figures out what the user pointed us at and what that means for
5
+ # scanning (which .rb files) and dynamic probing (which entry).
6
+ #
7
+ # Detection precedence for directories: Rails beats Rack (a Rails
8
+ # root always has a config.ru), Rack beats gem (an app may vendor a
9
+ # gemspec), gem beats plain directory.
10
+ class Target
11
+ EXCLUDED_DIRS = %w[
12
+ vendor node_modules tmp log coverage pkg .git .bundle
13
+ ].freeze
14
+
15
+ # @return [Symbol] one of `:script`, `:gem`, `:rack`, `:rails`,
16
+ # `:directory`, `:bundle`
17
+ attr_reader :type
18
+
19
+ # @return [String] the target's root directory
20
+ attr_reader :root
21
+
22
+ # @return [Array<String>] Ruby files to scan statically
23
+ attr_reader :ruby_files
24
+
25
+ # @return [Hash, nil] dynamic probe entry (`:mode` plus
26
+ # mode-specific keys), nil for static-only targets
27
+ attr_reader :entry
28
+
29
+ # Detects what `raw` points at and builds the target.
30
+ #
31
+ # @param raw [String] a `.rb`/`.ru` file, a directory, a
32
+ # `Gemfile.lock`, or an installed gem name
33
+ # @return [Target]
34
+ # @raise [Audition::Error] when nothing matches
35
+ def self.detect(raw)
36
+ raw = normalize(raw)
37
+ if File.file?(raw)
38
+ from_file(raw)
39
+ elsif File.directory?(raw)
40
+ from_directory(raw)
41
+ else
42
+ from_gem_name(raw)
43
+ end
44
+ end
45
+
46
+ def self.from_file(path)
47
+ if File.basename(path) == "Gemfile.lock"
48
+ return new(
49
+ type: :bundle,
50
+ root: File.expand_path("..", path),
51
+ ruby_files: [],
52
+ entry: {mode: :bundle, lockfile: path}
53
+ )
54
+ end
55
+ unless path.end_with?(".rb", ".ru")
56
+ raise Error, "#{path} is not a Ruby file"
57
+ end
58
+
59
+ new(
60
+ type: :script,
61
+ root: File.dirname(path),
62
+ ruby_files: [path],
63
+ entry: {mode: :script, path: path}
64
+ )
65
+ end
66
+
67
+ def self.from_directory(dir)
68
+ application_rb = File.join(dir, "config", "application.rb")
69
+ config_ru = File.join(dir, "config.ru")
70
+ gemspec = Dir[File.join(dir, "*.gemspec")].first
71
+
72
+ if File.file?(application_rb)
73
+ rails_target(dir)
74
+ elsif File.file?(config_ru)
75
+ rack_target(dir, config_ru)
76
+ elsif gemspec
77
+ gem_dir_target(dir, gemspec)
78
+ else
79
+ new(
80
+ type: :directory,
81
+ root: dir,
82
+ ruby_files: glob(dir),
83
+ entry: nil
84
+ )
85
+ end
86
+ end
87
+
88
+ def self.from_gem_name(name)
89
+ spec = Gem::Specification.find_by_name(name)
90
+ new(
91
+ type: :gem,
92
+ root: spec.full_gem_path,
93
+ ruby_files: spec.require_paths.flat_map do |rp|
94
+ glob(File.join(spec.full_gem_path, rp))
95
+ end,
96
+ entry: {mode: :require, feature: name,
97
+ root: spec.full_gem_path}
98
+ )
99
+ rescue Gem::MissingSpecError
100
+ raise Error,
101
+ "#{name} is not a file, directory, or installed gem"
102
+ end
103
+
104
+ def self.rails_target(dir)
105
+ files = %w[app lib config].flat_map { |d| glob(File.join(dir, d)) }
106
+ files << File.join(dir, "config.ru")
107
+ new(
108
+ type: :rails,
109
+ root: dir,
110
+ ruby_files: files.select { |f| File.file?(f) },
111
+ entry: {
112
+ mode: :rails,
113
+ environment: File.join(dir, "config", "environment.rb"),
114
+ root: dir
115
+ }
116
+ )
117
+ end
118
+
119
+ def self.rack_target(dir, config_ru)
120
+ new(
121
+ type: :rack,
122
+ root: dir,
123
+ ruby_files: [config_ru] + glob(dir),
124
+ entry: {mode: :rack, config_ru: config_ru}
125
+ )
126
+ end
127
+
128
+ def self.gem_dir_target(dir, gemspec)
129
+ lib = File.join(dir, "lib")
130
+ new(
131
+ type: :gem,
132
+ root: dir,
133
+ ruby_files: glob(lib),
134
+ entry: {
135
+ mode: :require,
136
+ feature: File.basename(gemspec, ".gemspec"),
137
+ load_paths: [lib],
138
+ root: dir
139
+ }
140
+ )
141
+ end
142
+
143
+ # Trailing slashes would break the prefix-stripping in glob
144
+ # ("dir//" never matches), silently excluding every file of a
145
+ # relative target.
146
+ def self.normalize(raw)
147
+ return raw if raw == "/"
148
+
149
+ raw.sub(%r{/+\z}, "")
150
+ end
151
+
152
+ def self.glob(dir)
153
+ dir = normalize(dir)
154
+ Dir[File.join(dir, "**", "*.rb")].reject do |path|
155
+ relative = path.delete_prefix("#{dir}/")
156
+ parts = relative.split("/")
157
+ parts.any? { |p| EXCLUDED_DIRS.include?(p) || p.start_with?(".") }
158
+ end.sort
159
+ end
160
+
161
+ private_class_method :from_file, :from_directory, :from_gem_name,
162
+ :rails_target, :rack_target, :gem_dir_target,
163
+ :glob, :normalize
164
+
165
+ def initialize(type:, root:, ruby_files:, entry:)
166
+ @type = type
167
+ @root = root
168
+ @ruby_files = ruby_files
169
+ @entry = entry
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Audition
4
+ VERSION = "0.1.0"
5
+ end
data/lib/audition.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "audition/version"
4
+ require_relative "audition/finding"
5
+ require_relative "audition/target"
6
+ require_relative "audition/static/source_file"
7
+ require_relative "audition/static/literal_classifier"
8
+ require_relative "audition/static/checks"
9
+ require_relative "audition/static/graph_audit"
10
+ require_relative "audition/static/analyzer"
11
+ require_relative "audition/dynamic/prober"
12
+ require_relative "audition/report"
13
+ require_relative "audition/config"
14
+ require_relative "audition/baseline"
15
+ require_relative "audition/directives"
16
+ require_relative "audition/fixer"
17
+ require_relative "audition/bundle_sweep"
18
+ require_relative "audition/cli"
19
+
20
+ # Probes Ruby code for the ability to run inside Ractors: static
21
+ # analysis (Prism per-file checks plus whole-program rubydex graph
22
+ # checks), dynamic in-Ractor probing in subprocesses, explanations
23
+ # and fixes for every finding, and a four-state verdict.
24
+ #
25
+ # The command line lives in {Audition::CLI}; library consumers
26
+ # usually start from {Audition::Target.detect} and
27
+ # {Audition::Static::Analyzer}.
28
+ #
29
+ # @example Audit one file programmatically
30
+ # analyzer = Audition::Static::Analyzer.new
31
+ # findings = analyzer.analyze_path("app/models/user.rb")
32
+ # findings.each { |f| puts "#{f.location}: #{f.message}" }
33
+ module Audition
34
+ # Raised for user-facing failures: unknown targets, unreadable
35
+ # config, malformed baselines. The CLI converts it to exit code 2.
36
+ class Error < StandardError; end
37
+ end