Class: Audition::Static::GraphAudit
- Inherits:
-
Object
- Object
- Audition::Static::GraphAudit
- Defined in:
- lib/audition/static/graph_audit.rb
Overview
Whole-program semantic checks backed by the rubydex graph.
rubydex resolves state to its true owner, so an ivar written in
the class body, in def self.x, and inside class << self,
across several files, unifies into one declaration owned by the
singleton class. Per-file AST visitors cannot see that.
Defined Under Namespace
Classes: Context
Constant Summary collapse
- CVAR_WHY =
"Class variables cannot be accessed from non-main Ractors " \ "at all; both reads and writes raise " \ "Ractor::IsolationError (\"can not access class variables " \ "from non-main Ractors\")."
- CVAR_FIX =
"Replace with a deeply frozen constant, per-instance " \ "state, or Ractor-local storage (Ractor.current[:key], " \ "Ractor.store_if_absent)."
- STATE_WHY =
"This instance variable lives on the class/module object, " \ "which is shared across Ractors; non-main Ractors raise " \ "Ractor::IsolationError when writing it, and when reading " \ "it while it holds a non-shareable value (verified on " \ "Ruby 4.0)."
- STATE_FIX =
"Precompute and freeze the value at load time: a memo " \ "that needs no configuration becomes a frozen private " \ "constant (EMPTY = new(nil, nil).freeze), a cheap " \ "derivation drops its memo altogether, and " \ "per-subclass values compute in the inherited hook " \ "(guard on subclass.name for anonymous classes). For " \ "collections, rebuild and refreeze on write, " \ "copy-on-write: self.list = (list + [item]).freeze " \ "when every element is shareable, self.list = " \ "Ractor.make_shareable(list + [item]) when the " \ "additions may be unfrozen, since a plain freeze is " \ "shallow; never mutate in place. As a last resort use " \ "Ractor.store_if_absent for per-Ractor state, or read " \ "the ivar first and proxy the write to the main Ractor."
- PROXIED_WHY =
"The read is the lock-free fast path and the write runs " \ "on the main Ractor through on_main, so no non-main " \ "Ractor ever writes class state. Every worker waiting " \ "on main serializes there, and a worker's next read " \ "raises Ractor::IsolationError unless the memoized " \ "value is shareable."
- PROXIED_FIX =
"Keep the hatch rare: delete the memo or warm it at " \ "boot where possible, and make the computed value " \ "deeply frozen inside the block (.freeze or " \ "Ractor.make_shareable) so workers can read it."
- FROZEN_MEMO_WHY =
"Every write memoizes a shareable (frozen) value, so " \ "non-main Ractors can read it once it has been " \ "computed; only the first write must happen on the " \ "main Ractor, or it raises Ractor::IsolationError."
- FROZEN_MEMO_FIX =
"Warm the cache at boot, before spawning Ractors: call " \ "the memoizing method from an initializer, an on_load " \ "hook, an eager_load! override, or the inherited hook. " \ "A value that can be nil or false never sticks under " \ "||=, so guard it with defined? instead. If the value " \ "genuinely must be computed at runtime, read the ivar " \ "first and proxy only the write to the main Ractor, or " \ "use Ractor.store_if_absent."
- BEST_EFFORT_WHY =
"Writes wrap their value in Ractor.make_shareable with " \ "a rescue fallback: shareable values are deeply frozen " \ "and readable from any Ractor, while unshareable values " \ "keep their old (Ractor-hostile) behavior. Whether this " \ "state is actually safe depends on what the application " \ "assigns; the dynamic probe reports ground truth."
- BEST_EFFORT_FIX =
"Assign only shareable values (strings, symbols, frozen " \ "containers) before spawning Ractors. Configuration " \ "that cannot be shareable needs per-Ractor state or a " \ "main-Ractor proxy instead."
- DERIVED_WHY =
"The value comes from another constant whose own " \ "definition is already flagged: freezing this one is " \ "shallow and does not change what the referent holds, " \ "so a non-main Ractor reading it can raise the same " \ "Ractor::IsolationError."
- DERIVED_FIX =
"Make the referenced constant deeply shareable first; " \ "this finding follows its verdict."
- CONTAINED_WHY =
"The assigned expression is flagged on this same line: " \ "the constant holds whatever unshareable value that " \ "expression produces, so a non-main Ractor reading the " \ "constant hits the same problem."
- SINGLETON_SCAN_WHY =
"The receiver is a runtime value, so the graph cannot " \ "attribute the state this body writes to any class. If " \ "the receiver is one, these are class-level instance " \ "variables and a non-main Ractor raises " \ "Ractor::IsolationError writing them."
- SINGLETON_SCAN_FIX =
"Open the singleton on the constant itself so the state " \ "resolves, or confirm the receiver with the dynamic " \ "probe, which reads the live object graph."
- ANCESTOR_SCAN_WHY =
"The ancestor is a runtime value, so whatever it " \ "contributes—class-level state, class variables, " \ "hostile APIs—is invisible here. A clean report for " \ "this class covers only what the class itself declares."
- ANCESTOR_SCAN_FIX =
"Name the superclass or module directly where the " \ "hierarchy is static. Where it cannot be, audit the " \ "candidates separately; the dynamic probe sweeps the " \ "ancestors that are actually in play."
- CONSTANT_SCAN_WHY =
"The path starts from a runtime value, so the constant " \ "this assignment defines holds something the shareability " \ "checks never saw."
- CONSTANT_SCAN_FIX =
"Reference the constant by its static path, or let the " \ "dynamic probe check the value with Ractor.shareable?."
- PROPAGATED_CHECKS =
Findings from these per-file checks seed the derived- constant propagation.
[ "mutable-constants", "unshareable-reads", "native-gem-calls" ].freeze
- EXPRESSION_CHECKS =
Checks that flag an expression rather than the constant it may be assigned to; when such a finding sits inside a constant assignment, the constant inherits it by name.
["unshareable-reads", "native-gem-calls"].freeze
- SCAN_RULES =
The expressions rubydex reports as unresolvable. Each is a hole in the walks above, so where the shape could hide something they look for, the hole is reported. rubydex's parse rules are left out—the per-file syntax check already reports those—as are its visibility rules, which say nothing about Ractors.
{ "DynamicSingletonDefinition" => :singleton, "DynamicAncestor" => :ancestor, "DynamicConstantReference" => :constant }.freeze
- MIXINS =
["include", "extend", "prepend"].freeze
- STATE_WRITES =
[ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode, Prism::ClassVariableWriteNode, Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode, Prism::ClassVariableOperatorWriteNode ].freeze
Instance Method Summary collapse
-
#analyze_paths(paths, constant_findings: [], workers: nil, progress: Progress::SILENT) ⇒ Array<Finding>
rubydex's index_all descends directories but skips bare file lists, so files are fed through index_source individually.
- #analyze_sources(sources, constant_findings: [], workers: nil, progress: Progress::SILENT) ⇒ Array<Finding>
-
#gathered_names(sources) ⇒ Hash{Symbol => Object}
private
Names one slice of a scan declares, for a parallel audit to merge before any walk emits.
-
#walk_batches(sources, seen, names) ⇒ Array<Array<Finding>>
private
The class-state walks over one slice, against names gathered from the whole scan.
Instance Method Details
#analyze_paths(paths, constant_findings: [], workers: nil, progress: Progress::SILENT) ⇒ Array<Finding>
rubydex's index_all descends directories but skips bare file lists, so files are fed through index_source individually.
194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/audition/static/graph_audit.rb', line 194 def analyze_paths(paths, constant_findings: [], workers: nil, progress: Progress::SILENT) sources = {} progress.stage("reading", total: paths.size) paths.each do |path| progress.tick sources[path] = File.read(path) rescue SystemCallError next end analyze_sources(sources, constant_findings: constant_findings, workers: workers, progress: progress) end |
#analyze_sources(sources, constant_findings: [], workers: nil, progress: Progress::SILENT) ⇒ Array<Finding>
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/audition/static/graph_audit.rb', line 167 def analyze_sources(sources, constant_findings: [], workers: nil, progress: Progress::SILENT) graph = Rubydex::Graph.new progress.stage("indexing", total: sources.size) sources.each do |path, code| progress.tick graph.index_source(path, code, "ruby") end @sources = sources @workers = workers @frozen_memos = frozen_memo_map(sources) @reported_lines = constant_findings .map { |f| [f.path, f.line] }.to_set @constant_findings = constant_findings.select do |f| PROPAGATED_CHECKS.include?(f.check) end audit(graph, progress) end |
#gathered_names(sources) ⇒ Hash{Symbol => Object}
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Names one slice of a scan declares, for a parallel audit to merge before any walk emits.
214 215 216 217 218 |
# File 'lib/audition/static/graph_audit.rb', line 214 def gathered_names(sources) @sources = sources {writers: declared_writers, declared: declared_names, extended: extended_names} end |
#walk_batches(sources, seen, names) ⇒ Array<Array<Finding>>
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The class-state walks over one slice, against names gathered from the whole scan.
228 229 230 231 232 233 234 |
# File 'lib/audition/static/graph_audit.rb', line 228 def walk_batches(sources, seen, names) @sources = sources [singleton_attr_findings, extended_module_findings(seen, names[:extended]), dynamic_ivar_findings(seen, names[:declared]), attribute_write_findings(seen, names[:writers])] end |