Class: Audition::Static::GraphAudit

Inherits:
Object
  • Object
show all
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

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.

Parameters:

  • paths (Array<String>) —

    files to index and audit

  • constant_findings (Array<Finding>) (defaults to: []) —
  • progress (Progress) (defaults to: Progress::SILENT)

Returns:



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>

Parameters:

  • sources (Hash{String => String}) —

    path => source

  • constant_findings (Array<Finding>) (defaults to: []) —

    per-file findings whose definition sites seed derived-constant propagation

  • progress (Progress) (defaults to: Progress::SILENT)

Returns:



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.

Parameters:

  • sources (Hash{String => String}) —

    path => source

Returns:

  • (Hash{Symbol => Object})


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.

Parameters:

  • sources (Hash{String => String}) —

    path => source

  • seen (Set) —

    path/line pairs a declaration claimed

  • names (Hash) —

    from #gathered_names, merged

Returns:

  • (Array<Array<Finding>>) —

    one batch per walk



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