rigortype 0.3.2 → 0.3.4

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.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # An immutable, sorted, de-duplicated set of effect labels — the carrier of a summary lane and
8
+ # of an envelope's bound (ADR-103 WD1).
9
+ #
10
+ # Two sentinels bracket the lattice. {EMPTY} is the empty set: "no effects", the reading of
11
+ # `%a{pure}` modulo `mutate.local`. {TOP} is the unbounded / unspecified reading: it admits
12
+ # every label, it absorbs every join, and it is what a tag carrying an unknown label degrades
13
+ # to (the fail-open rule). {TOP} is NOT the set of all registered labels and is deliberately
14
+ # not enumerable — `top?` is the only way to tell it from {EMPTY} through `to_a`.
15
+ #
16
+ # Instances are frozen on construction and hold a frozen array, so they cross a Ractor boundary
17
+ # and go into a cache entry as they are.
18
+ class LabelSet
19
+ NO_LABELS = [].freeze
20
+ private_constant :NO_LABELS
21
+
22
+ # Build a set from any enumerable of label strings. Members are normalised (de-duplicated and
23
+ # sorted) so equality is structural and a snapshot rendering is deterministic.
24
+ #
25
+ # Raises `ArgumentError` on a member that does not satisfy {Label::PATTERN}: a LabelSet is a
26
+ # value object over the grammar, and the fail-open handling of an unrecognised *spelling* is
27
+ # the reader's job (it yields {TOP}), not this constructor's.
28
+ #
29
+ # `top:` is internal — it exists to build {TOP} and is not part of the surface later slices
30
+ # build on.
31
+ def initialize(labels = NO_LABELS, top: false)
32
+ @top = top
33
+ @labels = NO_LABELS
34
+ unless top
35
+ members = labels.to_a
36
+ members.each do |label|
37
+ raise ArgumentError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
38
+ end
39
+ @labels = members.uniq.sort.freeze
40
+ end
41
+ freeze
42
+ end
43
+
44
+ # The unbounded set. Everything is subsumed by it; joining it with anything yields it.
45
+ TOP = new(NO_LABELS, top: true)
46
+
47
+ # The empty set — no effects at all.
48
+ EMPTY = new(NO_LABELS)
49
+
50
+ # Whether this is the unbounded sentinel {TOP}.
51
+ def top?
52
+ @top
53
+ end
54
+
55
+ # Whether this set records no labels. {TOP} is not empty: it records nothing because it
56
+ # stands for everything.
57
+ def empty?
58
+ !@top && @labels.empty?
59
+ end
60
+
61
+ # The members, sorted, as a frozen array. {TOP} yields `[]` — consult {top?} first.
62
+ def to_a
63
+ @labels
64
+ end
65
+
66
+ # Exact membership among the recorded labels. `LabelSet.new(["io"]).include?("io.net")` is
67
+ # false — that question is {admits?}. {TOP} records no members, so it includes none.
68
+ def include?(label)
69
+ @labels.include?(label)
70
+ end
71
+
72
+ # Whether some member of this set subsumes `label`. {TOP} admits every well-formed label.
73
+ def admits?(label)
74
+ return Label.valid?(label) if @top
75
+
76
+ @labels.any? { |member| Label.subsumes?(member, label) }
77
+ end
78
+
79
+ # Union. {TOP} absorbs: a join involving it is {TOP}.
80
+ #
81
+ # A join that adds nothing returns `self` **without allocating**. That is the common case wherever
82
+ # sets are joined in a loop — the propagator's fixpoint re-joins every edge on every visit — and the
83
+ # sets are single-digit-sized, so the containment scan is cheaper than the construction it avoids.
84
+ def join(other)
85
+ return TOP if @top || other.top?
86
+ return other if @labels.empty?
87
+
88
+ others = other.to_a
89
+ return self if others.empty?
90
+ return self if others.all? { |label| @labels.include?(label) }
91
+
92
+ self.class.new(@labels + others)
93
+ end
94
+
95
+ # The members `other` does not already admit — the **declared lane's rendering rule**. A declared
96
+ # `io.net.http` beside a proven `io.net` says nothing the proven lane did not already say more
97
+ # strongly, so a renderer drops it rather than printing `[io.net] ≤ [io.net.http]`, which reads as
98
+ # two facts where there is one. Applied where output is produced, never to the table: the lanes
99
+ # themselves stay raw, because a later join has to see what was actually declared.
100
+ def excluding_subsumed_by(other)
101
+ return self if @top || other.empty?
102
+
103
+ kept = @labels.reject { |label| other.admits?(label) }
104
+ return self if kept.length == @labels.length
105
+
106
+ kept.empty? ? EMPTY : self.class.new(kept)
107
+ end
108
+
109
+ # Whether every member of this set is admitted by `bound_set` — the envelope check, modulo
110
+ # the policy discharge that happens at judgment time. {TOP} is bounded only by {TOP}.
111
+ def subsumed_by?(bound_set)
112
+ return true if bound_set.top?
113
+ return false if @top
114
+
115
+ @labels.all? { |label| bound_set.admits?(label) }
116
+ end
117
+
118
+ def ==(other)
119
+ return true if equal?(other)
120
+
121
+ other.is_a?(LabelSet) && other.top? == @top && other.to_a == @labels
122
+ end
123
+ alias eql? ==
124
+
125
+ def hash
126
+ [self.class, @top, @labels].hash
127
+ end
128
+
129
+ def inspect
130
+ return "#<Rigor::Effects::LabelSet TOP>" if @top
131
+
132
+ "#<Rigor::Effects::LabelSet #{@labels.join(', ')}>"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envelope"
4
+ require_relative "envelope_check"
5
+ require_relative "method_key"
6
+ require_relative "path_finder"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # Judges an override against the envelope it inherits (ADR-103 WD1 / WD14; #386). The one place
11
+ # `effect.liskov-widened` is decided.
12
+ #
13
+ # An envelope is a contract about a method, and Ruby's `class PgRepo < Repo` says a `PgRepo` is
14
+ # usable wherever a `Repo` is. So a bound written on `Repo#find` binds `PgRepo#find` too:
15
+ # **implementations may be purer than the bound they inherit, never less pure.** That is Liskov
16
+ # inclusion applied to the second dimension, and it is what makes the declared lane's nominal carrier
17
+ # (#386, {EnvelopeIndex}) honest — a caller that imports `≤ io.db` from a `Repo`-typed receiver is
18
+ # entitled to that bound whichever subclass actually arrives.
19
+ #
20
+ # Two comparisons, and an override is subject to exactly one of them:
21
+ #
22
+ # - **Proven against the inherited bound** — the override declares nothing of its own, so what it
23
+ # *does* is what the ancestor's bound has to admit. It reads the same lane
24
+ # {EnvelopeCheck} reads, for the same reasons: the proven closure, undischarged per policy,
25
+ # `mutate.local` tolerated, taint ignored.
26
+ # - **Declared against the inherited bound** — the override declares its own envelope, and a bound
27
+ # wider than the one it inherits is a Liskov violation in the *declaration*, before any body is
28
+ # consulted. Two authored bounds compared by subsumption; nothing proven enters it.
29
+ #
30
+ # The split is exclusive on purpose. An override that declares its own envelope is already held to
31
+ # that envelope by `effect.envelope-exceeded`, so running the proven comparison too would put two
32
+ # diagnostics on one line for one label. What the author asserted is the thing Liskov has to judge;
33
+ # whether the body honours the assertion is the other rule's question.
34
+ #
35
+ # Both-sides-authored, in the [ADR-35](../../../docs/adr/35-override-signature-compatibility.md)
36
+ # sense: nothing fires unless an author wrote an envelope on the ancestor. That is the accepted
37
+ # construction the false-positive budget is spent under — a firing is never unsolicited.
38
+ #
39
+ # **Nominal subclassing only.** An included module's method is not an override in this slice: Ruby's
40
+ # ancestry puts an includer's own `def` *ahead* of the module's rather than under it, and the
41
+ # substitutability argument that licenses the check is the subclass one. The `superclasses` table is
42
+ # the collector's own, as-written and resolved here exactly as {Propagator} resolves it, so the
43
+ # relation the check reads and the closed world the proven lane travels can never disagree.
44
+ module LiskovCheck
45
+ # One (override, exceeding label) pair.
46
+ #
47
+ # `own_envelope` is the override's own bound when there is one, which is also what selects the
48
+ # message variant: present means the declaration-level comparison produced this finding, nil means
49
+ # the proven one. `chain` / `origin` explain the proven variant and are nil for the other, which
50
+ # has no path to walk — a declaration is not proved by anything.
51
+ Finding = Data.define(:key, :label, :ancestor_key, :ancestor_envelope, :own_envelope, :path, :line,
52
+ :chain, :origin)
53
+
54
+ NO_FINDINGS = [].freeze
55
+ private_constant :NO_FINDINGS
56
+
57
+ module_function
58
+
59
+ # @param table [EffectTable] the run's propagated graph.
60
+ # @param superclasses [Hash{String => Array<String>}] the collector's as-written superclass
61
+ # candidate lists (`FileCollection#superclasses`).
62
+ # @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
63
+ # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
64
+ # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
65
+ # to the classes they select.
66
+ # @param positions [EnvelopeCheck::Positions] where the override's `def` is.
67
+ # @param apply_tolerated [Boolean] false judges against `proven` — `--no-tolerated-effects`.
68
+ # @return [Array<Finding>] sorted by position then key then label.
69
+ def run(table:, superclasses:, method_envelopes:, class_envelopes:, config_envelopes: {},
70
+ positions: EnvelopeCheck::Positions.empty, apply_tolerated: true)
71
+ # The distributed strata, over a base of the raw per-method annotations: a base class whose method
72
+ # exists only in `.rbs` — an abstract `def find: (Integer) -> User` with no Ruby body — has no
73
+ # key in the table and so no distributed entry, and its bound is exactly the one an override
74
+ # inherits. Distribution wins on collision, which is the same value for a key that has both.
75
+ envelopes = method_envelopes.merge(
76
+ EnvelopeCheck.distribute(table, method_envelopes, class_envelopes, config_envelopes)
77
+ )
78
+ return NO_FINDINGS if envelopes.empty?
79
+
80
+ parents = parent_map(table, superclasses)
81
+ return NO_FINDINGS if parents.empty?
82
+
83
+ findings = []
84
+ keys = table.keys
85
+ keys.each { |key| collect(findings, table, key, envelopes, parents, positions, apply_tolerated) }
86
+ findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
87
+ end
88
+
89
+ # `{child class => parent class}`, one parent per child.
90
+ #
91
+ # An ancestry name is recorded as written, so `class Loud < Base` inside `module Tracer` arrives as
92
+ # the candidate list Ruby's own lexical lookup would try, most-qualified first. The most-qualified
93
+ # candidate the project actually defines wins — the same rule {Propagator::Index#build_descendants}
94
+ # applies, and for the same reason: without it `A::Base` and `B::Base` would share the bare spelling
95
+ # `Base` and an unrelated class's envelope would bind an override that never inherited it.
96
+ def parent_map(table, superclasses)
97
+ return {} if superclasses.nil? || superclasses.empty?
98
+
99
+ known = table.keys.filter_map { |key| MethodKey.owner(key) }.to_set
100
+ superclasses.each_with_object({}) do |(child, candidates), out|
101
+ parent = Array(candidates).find { |candidate| known.include?(candidate) }
102
+ out[child] = parent if parent && parent != child
103
+ end
104
+ end
105
+
106
+ def collect(findings, table, key, envelopes, parents, positions, apply_tolerated)
107
+ class_name, separator, selector = MethodKey.split(key)
108
+ return if class_name.nil?
109
+
110
+ ancestor_key = inherited_key(class_name, separator, selector, envelopes, parents)
111
+ return if ancestor_key.nil?
112
+
113
+ inherited = envelopes.fetch(ancestor_key)
114
+ own = envelopes[key]
115
+ position = positions.for(key)
116
+ if own && !own.top?
117
+ collect_declared(findings, key, ancestor_key, inherited, own, position)
118
+ else
119
+ collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
120
+ end
121
+ end
122
+
123
+ # The nearest ancestor whose own method key carries an envelope. Nearest wins for the same reason
124
+ # it does among strata: a bound written closer to the override is the more specific statement about
125
+ # it, and a grandparent's bound is already binding on the parent that sits between them.
126
+ def inherited_key(class_name, separator, selector, envelopes, parents)
127
+ seen = Set.new([class_name])
128
+ current = parents[class_name]
129
+ while current && seen.add?(current)
130
+ candidate = "#{current}#{separator}#{selector}"
131
+ return candidate if envelopes.key?(candidate) && !envelopes.fetch(candidate).top?
132
+
133
+ current = parents[current]
134
+ end
135
+ nil
136
+ end
137
+
138
+ def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
139
+ entry = table[key]
140
+ return if entry.nil?
141
+
142
+ exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
143
+ exceeding.each do |label|
144
+ trail = PathFinder.shortest(table, symbol: key, label: label)
145
+ findings << Finding.new(
146
+ key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
147
+ own_envelope: nil, path: position.first, line: position.last,
148
+ chain: trail&.chain || [key].freeze, origin: trail&.origin
149
+ )
150
+ end
151
+ end
152
+
153
+ # Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
154
+ # everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
155
+ def collect_declared(findings, key, ancestor_key, inherited, own, position)
156
+ own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
157
+ findings << Finding.new(
158
+ key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
159
+ own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
160
+ )
161
+ end
162
+ end
163
+
164
+ private_class_method :parent_map, :collect, :inherited_key, :collect_proven, :collect_declared
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/node_children"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Which of a method body's locals the frame **owns** — freshly allocated here and never let out
10
+ # (ADR-103 WD4; the proof obligations are `control-flow-analysis.md` § Proof obligations).
11
+ #
12
+ # Ruby has no by-ref parameters, so `mutate.local` cannot mean "a write into an out-parameter" as it
13
+ # does in PHP. It means the mutated receiver is invisible to the caller, and that is an ownership
14
+ # question: a local whose every assignment allocates (`[]`, `{}`, `""`, `.new`, `.dup`) and which never
15
+ # escapes the body is one no caller can observe being mutated.
16
+ #
17
+ # The analysis is deliberately **flow-insensitive and whole-body**: a local that escapes anywhere
18
+ # disqualifies, even if the escape happens after the mutation. That is strictly more conservative than
19
+ # the "escaped before the mutating call" reading, which is the direction false positives are budgeted
20
+ # in ([ADR-5](../adr/5-robustness-principle.md)) — an unproven local mutation becomes an
21
+ # `unknown-ownership` taint, never a proven `mutate` label.
22
+ #
23
+ # This is the tracer slice's approximation, not the eventual answer. `ClosureEscapeAnalyzer` answers a
24
+ # different question (fact retention, not "does the code contain") and is deliberately left alone.
25
+ module LocalOwnership
26
+ # Assignment right-hand sides that witness a fresh allocation. `.new` and `.dup` / `.clone` follow
27
+ # [ADR-76](../adr/76-effect-modeling-freeze-dup-shape-preservation.md)'s reading of `dup` as the
28
+ # allocation witness.
29
+ ALLOCATING_SELECTORS = %i[new dup clone].to_set.freeze
30
+
31
+ module_function
32
+
33
+ # The set of frame-owned local names in `body`, given the method's parameter names (a parameter is
34
+ # never frame-owned — the caller holds the same object, so mutating it is `mutate.instance`).
35
+ def owned(body, parameter_names)
36
+ return Set.new if body.nil?
37
+
38
+ assignments = {}
39
+ escaped = Set.new
40
+ collect(body, assignments, escaped)
41
+ escaped.merge(trailing_reads(body))
42
+ assignments.filter_map do |name, values|
43
+ next if escaped.include?(name) || parameter_names.include?(name)
44
+
45
+ name if values.all? { |value| allocation?(value) }
46
+ end.to_set
47
+ end
48
+
49
+ # Whether `node` is an expression that allocates a fresh object this frame is the sole holder of.
50
+ def allocation?(node)
51
+ case node
52
+ when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode,
53
+ Prism::LambdaNode
54
+ true
55
+ when Prism::CallNode
56
+ ALLOCATING_SELECTORS.include?(node.name) || unary_plus_string?(node)
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ # `+""` — the frozen-string-literal era's spelling of "a fresh mutable String".
63
+ def unary_plus_string?(node)
64
+ node.name == :+@ && node.receiver.is_a?(Prism::StringNode)
65
+ end
66
+
67
+ def collect(node, assignments, escaped)
68
+ return unless node.is_a?(Prism::Node)
69
+
70
+ record_assignment(node, assignments, escaped)
71
+ record_escapes(node, escaped)
72
+ node.rigor_each_child { |child| collect(child, assignments, escaped) }
73
+ end
74
+
75
+ def record_assignment(node, assignments, escaped)
76
+ case node
77
+ when Prism::LocalVariableWriteNode
78
+ (assignments[node.name.to_s] ||= []) << node.value
79
+ # `y = x` hands the same object to a second name; neither can be proven frame-private cheaply.
80
+ escaped << node.value.name.to_s if node.value.is_a?(Prism::LocalVariableReadNode)
81
+ when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
82
+ Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
83
+ # Not an allocation, and a multi-assign target's value is not statically one either: record a
84
+ # nil right-hand side so the all-allocations test fails.
85
+ (assignments[node.name.to_s] ||= []) << nil
86
+ end
87
+ end
88
+
89
+ # An escape is any position from which a caller could later reach the object: a call argument (the
90
+ # callee may store it), the right-hand side of a write to state that outlives the frame, an element
91
+ # of a constructed collection, or an explicit `return`.
92
+ def record_escapes(node, escaped)
93
+ case node
94
+ when Prism::CallNode
95
+ node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
96
+ note_read(node.block.expression, escaped) if node.block.is_a?(Prism::BlockArgumentNode)
97
+ when Prism::ReturnNode
98
+ node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
99
+ when Prism::ArrayNode
100
+ node.elements.each { |element| note_read(element, escaped) }
101
+ else
102
+ note_read(stored_value(node), escaped)
103
+ end
104
+ end
105
+
106
+ # The value half of a write into state that outlives the frame, or of a hash entry. nil for every
107
+ # other node, which {note_read} ignores.
108
+ def stored_value(node)
109
+ case node
110
+ when Prism::AssocNode, Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
111
+ Prism::GlobalVariableWriteNode, Prism::ConstantWriteNode
112
+ node.value
113
+ end
114
+ end
115
+
116
+ def note_read(node, escaped)
117
+ escaped << node.name.to_s if node.is_a?(Prism::LocalVariableReadNode)
118
+ end
119
+
120
+ # A body whose value is a bare local read hands that local to the caller. Only the tail matters —
121
+ # every other position is covered by {record_escapes}.
122
+ def trailing_reads(body)
123
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
124
+ last = statements.last
125
+ last.is_a?(Prism::LocalVariableReadNode) ? [last.name.to_s] : []
126
+ end
127
+
128
+ private_class_method :collect, :record_assignment, :record_escapes, :stored_value, :note_read,
129
+ :trailing_reads
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # The spelling of an effect unit's key, in one place (ADR-103 WD14).
6
+ #
7
+ # `Owner#instance_method`, `Owner.singleton_method`, `<toplevel>#bare_def`. The separator is the FIRST
8
+ # `#` or `.` in the string, which is what makes `Net::HTTP.get` split at the dot rather than inside the
9
+ # namespace — a namespace carries `::`, never a bare dot, and a selector carries neither.
10
+ #
11
+ # This module exists because the split is now written on both sides of a contract: the scanner spells a
12
+ # key, and `effects.attribution:` in `.rigor.yml` names one. A key the loader accepts and the scanner
13
+ # would never produce is a table that silently matches nothing.
14
+ module MethodKey
15
+ module_function
16
+
17
+ # @return [Array(String, String, String), nil] `[owner, separator, selector]`, or nil when `key` is
18
+ # not a method key at all.
19
+ def split(key)
20
+ text = key.to_s
21
+ index = text.index("#") || text.index(".")
22
+ return nil if index.nil? || index.zero? || index == text.length - 1
23
+
24
+ [text[0, index], text[index], text[(index + 1)..]]
25
+ end
26
+
27
+ def valid?(key)
28
+ parts = split(key)
29
+ return false if parts.nil?
30
+
31
+ parts.none? { |part| part.match?(/\s/) }
32
+ end
33
+
34
+ # The owner half, or nil. What `keys_by_class`-shaped groupings ask for.
35
+ def owner(key)
36
+ split(key)&.first
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../inference/mutation_widening"
6
+ require_relative "label_set"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # Decides whether a call mutates its receiver, and — when it does — which `mutate.*` label that earns
11
+ # (ADR-103 WD4 / WD14).
12
+ #
13
+ # Two independent questions, both answered conservatively:
14
+ #
15
+ # 1. **Is this a mutation?** Only when the selector says so beyond doubt. `[]=` and an attribute writer
16
+ # are writes on every receiver; `<<` and the bang family are claimed only when the typer named the
17
+ # receiver's class, because `n << 2` is a bit shift and `io << "x"` is output. A wrong label in the
18
+ # proven lane is worse than a missing one — the proven lane is the one a verdict may read (ADR-5).
19
+ # 2. **Who owns the receiver?** `self` and its ivars are `mutate.self` (`mutate.static` in singleton
20
+ # context), a class variable is `mutate.static`, a parameter is `mutate.instance`, a frame-owned
21
+ # local is `mutate.local`. **Anything else answers nil**, and the caller records an
22
+ # `unknown-ownership` taint rather than a proven bare `mutate`: Ruby's ownership is a dataflow
23
+ # question, and a proven parent label on a fresh-but-unproven receiver would put findings on correct
24
+ # code (WD14).
25
+ class MutationClassifier
26
+ # The only selectors a mutation may be claimed from without knowing the receiver's class.
27
+ UNIVERSAL_MUTATORS = %i[[]=].to_set.freeze
28
+
29
+ # `String`'s receiver-mutating surface. `Array` / `Hash` reuse the hand-audited sets the widening
30
+ # rules already maintain, cited rather than re-derived (ADR-103 WD3).
31
+ STRING_MUTATORS = %i[
32
+ << concat replace insert prepend clear
33
+ upcase! downcase! capitalize! swapcase! reverse!
34
+ strip! lstrip! rstrip! chomp! chop! squeeze! succ! next!
35
+ sub! gsub! tr! tr_s! delete! slice! []=
36
+ ].to_set.freeze
37
+
38
+ # `foo=`, and deliberately not `==` / `<=` / `!=` / `===`.
39
+ ATTRIBUTE_WRITER = /\A[a-z_][A-Za-z0-9_]*=\z/
40
+
41
+ LABELS = {
42
+ self_state: LabelSet.new(["mutate.self"]),
43
+ static: LabelSet.new(["mutate.static"]),
44
+ instance: LabelSet.new(["mutate.instance"]),
45
+ local: LabelSet.new(["mutate.local"])
46
+ }.freeze
47
+
48
+ def initialize(singleton:, parameters:, owned_locals:)
49
+ @singleton = singleton
50
+ @parameters = parameters
51
+ @owned_locals = owned_locals
52
+ end
53
+
54
+ # Whether `node` mutates its receiver. `receiver_class` is the class the typer projected the
55
+ # receiver's type to, or nil when it projected to none.
56
+ def mutating?(node, receiver_class)
57
+ name = node.name
58
+ return true if UNIVERSAL_MUTATORS.include?(name) || ATTRIBUTE_WRITER.match?(name.to_s)
59
+
60
+ case receiver_class
61
+ when "Array" then Inference::MutationWidening::ARRAY_MUTATORS.include?(name)
62
+ when "Hash" then Inference::MutationWidening::HASH_MUTATORS.include?(name)
63
+ when "String" then STRING_MUTATORS.include?(name)
64
+ else false
65
+ end
66
+ end
67
+
68
+ # The label a mutation of `receiver` earns, or nil when ownership is not provable.
69
+ def label_for(receiver)
70
+ LABELS[ownership(receiver)]
71
+ end
72
+
73
+ private
74
+
75
+ def ownership(receiver)
76
+ case receiver
77
+ when nil, Prism::SelfNode, Prism::InstanceVariableReadNode
78
+ @singleton ? :static : :self_state
79
+ when Prism::ClassVariableReadNode then :static
80
+ when Prism::LocalVariableReadNode then local_ownership(receiver.name.to_s)
81
+ end
82
+ end
83
+
84
+ def local_ownership(name)
85
+ return :instance if @parameters.include?(name)
86
+ return :local if @owned_locals.include?(name)
87
+
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end