Class: Rigor::Effects::UnitScan

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/effects/unit_scan.rb

Overview

Scans one effect unit — a method body — into its direct Summary plus the unresolved edges the propagator later closes over (ADR-103 WD4).

Two rules shape the walk:

  • Containment. A block literal's origins always join the enclosing method's summary, whether the callee invokes the block now, later, or never; an envelope is a contract about the method's code. So the walk simply descends into BlockNodes and stops only at a nested def or at a define_method with a literal name, both of which are units of their own.
  • Observation. Everything the walk knows about a receiver comes from what the typer already decided at that call node (Collector::CallRecord); the scan resolves nothing, walks no callee, and touches no Scope.

What it cannot prove it taints, and a taint is never a finding — the summary reads "these effects, and possibly more". Long by construction: the walk carries one when per Ruby construct that originates an effect, and splitting that table across classes would put the vocabulary in one file and the reasons in another.

Constant Summary collapse

FRAME_LOCAL_GLOBALS =

$~ and friends are frame-local, not global state: a read of one is not global.read. (Prism gives $1 and $& node types of their own, so only the named specials need listing.)

%w[$~ $_ $& $` $' $+ $!].to_set.freeze
REFLECTIVE_SEND =
%i[send public_send __send__].to_set.freeze
DEFERRED_SELECTORS =

Selectors a per-class POSTURE default must never answer for, because a more specific reading of the same site exists and would be swallowed: send and friends are the dynamic-send taint, and call is the opaque-callable one. An explicit ROW still wins (Fiddle::Function#call is ffi) — it is only the class default that steps aside.

%i[send public_send __send__ call].to_set.freeze
EVAL_SELECTORS =

eval and its family with a string argument, and binding, hand the analyzer code it cannot read. The design note § 5.1 puts them outside the catalogue for exactly that reason: there is no upper bound to give, only the honest "and possibly more". The BLOCK forms (instance_eval { … }) are containment and must not taint, so the taint is conditioned on a positional argument being present.

%i[eval instance_eval class_eval module_eval].to_set.freeze
SELF_PATH_HEAD =

What a receiver rooted at implicit self spells as the head of a receiver path. Must agree with Rigor::Plugin::EffectAttribution::SELF_HEAD, which is what a plugin writes; spelled again rather than required so the effects namespace does not pull the plugin contract in, and pinned by spec.

"self"
GLOBAL_READ =
LabelSet.new(["global.read"])
GLOBAL_WRITE =
LabelSet.new(["global.write"])
MUTATE_STATIC =
LabelSet.new(["mutate.static"])
MUTATE_SELF =
LabelSet.new(["mutate.self"])
IO_PROCESS =
LabelSet.new(["io.process"])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty, plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil) ⇒ UnitScan

Returns a new instance of UnitScan.

Parameters:

  • singleton (Boolean)

    whether the unit's self is the class object (def self.x, class << self) — the axis that separates mutate.self from mutate.static on an ivar write

  • block_parameter (String, nil)

    the unit's &blk parameter name, if any; a call on it is forwarding, not an opaque callable

  • calls (Hash)

    node-identity table of Collector::CallRecords

  • attribution (Attribution) (defaults to: Attribution.empty)

    the project's effects.attribution: table

  • envelopes (EnvelopeIndex) (defaults to: EnvelopeIndex.empty)

    the envelopes a call site may import as a bound (#386)

  • plugin_facts (PluginFacts) (defaults to: PluginFacts.empty)

    the loaded plugins' effect_attributions: (#387)

  • owner_class (String, nil) (defaults to: nil)

    the class this unit is defined on — the carrier an implicit-self call's envelope is looked up under, since the syntax spells Kernel#name

  • method_name (String, nil) (defaults to: nil)

    this unit's own selector — what a super in its body names as the target the propagator resolves above owner_class (#446). With no name to state, a super taints instead.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/rigor/effects/unit_scan.rb', line 116

def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
               attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
               plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
  @singleton = singleton
  @block_parameter = block_parameter
  @calls = calls
  @attribution = attribution
  @envelopes = envelopes
  @plugin_facts = plugin_facts
  @owner_class = owner_class
  @method_name = method_name
  @mutation = MutationClassifier.new(
    singleton: singleton, parameters: parameters, owned_locals: owned_locals
  )
  @bundles = {}
  @declared_bundles = {}
  @causes = []
  @edges = []
  @nested = []
  @delegates_upward = false
end

Instance Attribute Details

#nestedObject (readonly)

Units discovered inside this one — a nested def, or a define_method with a literal name whose block becomes that method's body. Each is [name, singleton, body_node, parameters_node].



140
141
142
# File 'lib/rigor/effects/unit_scan.rb', line 140

def nested
  @nested
end

Class Method Details

.define_method_unit(node) ⇒ Array?

define_method(:literal) { … } — the block becomes literal's body (WD14), so the call is a unit declaration wherever it appears: in a class body it is the only way that method exists, and inside another method it is a definition the enclosing method performs (mutate.static) rather than code the enclosing method contains. A non-literal name has no key to file the block under, so it stays contained in the enclosing method and this returns nil.

Returns:

  • (Array, nil)

    [name, singleton, body, parameters]



91
92
93
94
95
96
97
98
99
100
101
# File 'lib/rigor/effects/unit_scan.rb', line 91

def self.define_method_unit(node)
  return nil unless node.name == :define_method && node.receiver.nil?

  first = node.arguments&.arguments&.first
  return nil unless first.is_a?(Prism::SymbolNode) && first.unescaped

  block = node.block
  return nil unless block.is_a?(Prism::BlockNode)

  [first.unescaped, false, block.body, block.parameters]
end

Instance Method Details

#delegates_upward?Boolean

Whether this body reaches super — an override that delegates upward still runs whatever the superclass does. Nested defs are unit boundaries, so a super counted here is this unit's.

A separate reading of the same node from the edge #visit_super records: what this bit answers is whether a framework's claim about the selector survives the class having written a body for it (FrameworkUnits.replaced?, #440), which is a question about the class, not about what the parent implementation does.

Returns:

  • (Boolean)


149
150
151
# File 'lib/rigor/effects/unit_scan.rb', line 149

def delegates_upward?
  @delegates_upward
end

#run(body) ⇒ Object

Walks body and returns [Summary, edges].



154
155
156
157
158
159
160
161
# File 'lib/rigor/effects/unit_scan.rb', line 154

def run(body)
  walk(body)
  summary = Summary.new(
    bundles: @bundles, declared_bundles: @declared_bundles,
    exhaustive: @causes.empty?, causes: @causes
  )
  [summary, @edges]
end