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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
|
@@ -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
|
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
|