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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+
6
+ require_relative "../inference/mutation_widening"
7
+ require_relative "label_set"
8
+ require_relative "mutation_classifier"
9
+ require_relative "narrowing"
10
+
11
+ module Rigor
12
+ module Effects
13
+ # The built-in effect catalogue — which core-library methods colour a summary, and with what
14
+ # (ADR-103 WD3 / WD14; the data is `data/effects/core.yml`, the contract is
15
+ # `docs/internal-spec/effect-summaries.md` § The catalogue).
16
+ #
17
+ # **The loader is deliberately dumb.** Every audit decision lives in the data file's `why:` lines;
18
+ # nothing here decides what a method does. What this class adds over `YAML.safe_load` is three
19
+ # things the data cannot express on its own:
20
+ #
21
+ # 1. **Per-class default postures.** A class the catalogue lists answers its posture's labels for a
22
+ # method it does not row, so the reading of Ruby's enormous core surface does not depend on the
23
+ # catalogue being complete. A class the catalogue does NOT list answers nil — contribute nothing,
24
+ # do not taint — which stays the reading for project and gem classes.
25
+ # 2. **Argument-dependent narrowing.** A row's `narrow:` names a {Narrowing} handler, which reads
26
+ # the call's own argument literals.
27
+ # 3. **Mutator sets by reference.** A value class names `mutators: array | hash | string` and the
28
+ # loader resolves it to the set the widening rules and the mutation classifier already maintain,
29
+ # so the two can never drift. The YAML never re-spells a mutator list.
30
+ #
31
+ # What this is **not** is a re-reading of `data/builtins/ruby_core/*.yml`'s `purity:` facet. That
32
+ # facet answers fold-safety in the C-dispatch sense — `Random#rand` is `leaf`, `Array#push` is
33
+ # `leaf` — and reading it as effect freedom would be wrong in both directions (ADR-103 WD3). This
34
+ # loader never opens `data/builtins`, and a spec asserts it.
35
+ class Catalog
36
+ class Error < StandardError
37
+ end
38
+
39
+ DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__)
40
+
41
+ # The mutator sets a value class may name, by reference. Adding a name here is the only way a
42
+ # class gets one — the data file may not spell a selector list of its own.
43
+ MUTATOR_SETS = {
44
+ "array" => Inference::MutationWidening::ARRAY_MUTATORS,
45
+ "hash" => Inference::MutationWidening::HASH_MUTATORS,
46
+ "string" => MutationClassifier::STRING_MUTATORS
47
+ }.freeze
48
+
49
+ NO_MUTATORS = Set[].freeze
50
+ private_constant :NO_MUTATORS
51
+
52
+ # What the catalogue answers for one call.
53
+ #
54
+ # `labels` may be empty two ways, and the difference matters: a ∅ **row** says the catalogue knows
55
+ # this call and knows it contributes nothing (which is what stops `Thread.new` from reading as an
56
+ # unresolved call while its block joins the enclosing method by containment), while a ∅ **posture**
57
+ # says only that the class is a value class. Both stop the caller treating the call as unresolved;
58
+ # only the posture keeps a project edge, because a project may reopen a core class.
59
+ class Entry < Data.define(:labels, :mutates_receiver, :from_posture)
60
+ def posture?
61
+ from_posture
62
+ end
63
+
64
+ def mutates_receiver?
65
+ mutates_receiver
66
+ end
67
+ end
68
+
69
+ # One row of the data file, resolved. `entry` is the row's own unnarrowed {Entry}, built at load
70
+ # time: the lookup sits inside the effect scan's walk, and every un-narrowed row hit in a project
71
+ # answers the same value.
72
+ Row = Data.define(:labels, :narrow, :mutates_receiver, :entry)
73
+ private_constant :Row
74
+
75
+ # One class of the data file, resolved: its two method buckets, its posture's labels, its mutator
76
+ # set, and the two memoised posture {Entry}s (allocated once rather than per call — the lookup
77
+ # sits inside the effect scan's walk).
78
+ class ClassEntry
79
+ attr_reader :instance_methods, :singleton_methods, :posture_labels, :singleton_posture_labels,
80
+ :mutators, :object
81
+
82
+ def initialize(instance_methods:, singleton_methods:, posture_labels:, singleton_posture_labels:,
83
+ mutators:, object:)
84
+ @instance_methods = instance_methods
85
+ @singleton_methods = singleton_methods
86
+ @posture_labels = posture_labels
87
+ @singleton_posture_labels = singleton_posture_labels
88
+ @mutators = mutators
89
+ @object = object
90
+ @entries = {
91
+ [false, false] => Entry.new(labels: posture_labels, mutates_receiver: false, from_posture: true),
92
+ [false, true] => Entry.new(labels: posture_labels, mutates_receiver: true, from_posture: true),
93
+ [true, false] => Entry.new(labels: singleton_posture_labels, mutates_receiver: false,
94
+ from_posture: true)
95
+ }.freeze
96
+ freeze
97
+ end
98
+
99
+ def object?
100
+ @object
101
+ end
102
+
103
+ # Memoised rather than built per call — the lookup sits inside the effect scan's walk. Only the
104
+ # instance side can be a receiver mutation, so the singleton side needs one entry.
105
+ def posture_entry(singleton, mutating)
106
+ @entries[[singleton, !singleton && mutating]]
107
+ end
108
+ end
109
+
110
+ # The shipped catalogue. Memoised: the YAML parse is a once-per-process cost, paid on the first
111
+ # collecting run rather than at `require "rigor"`, and inherited as-is across the fork pool
112
+ # (workers fork after load, and nothing here ever crosses a Marshal boundary — a `FileCollection`
113
+ # carries `LabelSet`s and Strings, never a catalogue reference).
114
+ def self.default
115
+ @default ||= load_file(DATA_PATH)
116
+ end
117
+
118
+ # Build a catalogue from a catalogue-shaped YAML file. A missing or unreadable file degrades to an
119
+ # empty catalogue rather than raising, matching {Registry.load_file} and the built-in catalogues'
120
+ # posture for a bare install that opted data out: every call then reads as uncatalogued, which is
121
+ # fail-open. A file that IS present but malformed raises — that is a packaging bug, not a choice.
122
+ def self.load_file(path)
123
+ raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
124
+ raw = {} unless raw.is_a?(Hash)
125
+ new(
126
+ defaults: raw["defaults"] || {}, classes: raw["classes"] || {},
127
+ universal: raw["universal"] || [], schema: raw.fetch("schema", 0),
128
+ digest: file_digest(path)
129
+ )
130
+ end
131
+
132
+ # A content digest of the catalogue file, so {#identity} moves when a row's audit decision moves.
133
+ # `schema:` alone cannot carry that: it is bumped for a shape change, and the whole point of the
134
+ # data file is that rows are edited without one. An absent file digests as `"absent"`, matching
135
+ # {load_file}'s fail-open posture for a bare install that opted data out.
136
+ def self.file_digest(path)
137
+ File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
138
+ rescue StandardError
139
+ "unreadable"
140
+ end
141
+ private_class_method :file_digest
142
+
143
+ attr_reader :schema, :digest
144
+
145
+ def initialize(defaults:, classes:, universal: [], schema: 0, digest: "unknown")
146
+ @schema = schema
147
+ @digest = digest.to_s.freeze
148
+ @postures = build_postures(defaults)
149
+ @universal = universal.to_set(&:to_s).freeze
150
+ @classes = build_classes(classes)
151
+ @object_constants = @classes.select { |_, entry| entry.object? }.keys.to_set.freeze
152
+ freeze
153
+ end
154
+
155
+ # What the effects cache identity records this catalogue as (#382, {Effects::Identity}): the schema
156
+ # AND the content digest, so a re-audited row invalidates persisted summaries the same way a shape
157
+ # change does.
158
+ def identity
159
+ "#{@schema}:#{@digest}"
160
+ end
161
+
162
+ # Every catalogued class name, sorted — the surface a spec walks.
163
+ def class_names
164
+ @classes.keys.sort
165
+ end
166
+
167
+ def class_entry(owner)
168
+ @classes[owner]
169
+ end
170
+
171
+ # Whether `name` is a constant that names an OBJECT rather than a class, so a call on it spells
172
+ # `ENV#[]` and not `ENV.[]`.
173
+ def object_constant?(name)
174
+ @object_constants.include?(name)
175
+ end
176
+
177
+ # What `owner#name` (or `owner.name`, with `singleton:`) contributes, or nil when the catalogue
178
+ # has nothing to say — which is not a taint, and leaves the caller to treat the call as ordinary.
179
+ #
180
+ # `call_node` is the Prism call, consulted only by a row that carries a `narrow:` handler.
181
+ # `posture:` false asks for a ROW ONLY, which is how the collector suppresses the class default
182
+ # where it would be wrong: an implicit-self call spells `Kernel#name`, and defaulting every
183
+ # unqualified call in a project body to `io` would colour the world.
184
+ def lookup(owner, name, singleton: false, call_node: nil, posture: true)
185
+ entry = @classes[owner]
186
+ return nil if entry.nil?
187
+
188
+ bucket = singleton ? entry.singleton_methods : entry.instance_methods
189
+ row = bucket[name]
190
+ return row_entry(row, call_node) if row
191
+ return UNIVERSAL if @universal.include?(name)
192
+ return nil unless posture
193
+
194
+ entry.posture_entry(singleton, entry.mutators.include?(name))
195
+ end
196
+
197
+ # An `Object`-level selector that exists on every receiver and touches nothing. Answered as a row
198
+ # rather than a posture, because it IS a statement about the selector.
199
+ UNIVERSAL = Entry.new(labels: LabelSet::EMPTY, mutates_receiver: false, from_posture: false)
200
+
201
+ private
202
+
203
+ # A narrowed row's own `effects:` is its **unnarrowed** answer — the upper bound the handler
204
+ # degrades to — so a caller that has no call node still gets a sound reading rather than ∅.
205
+ def row_entry(row, call_node)
206
+ return row.entry unless row.narrow && call_node
207
+
208
+ Entry.new(labels: Narrowing.apply(row.narrow, call_node), mutates_receiver: row.mutates_receiver,
209
+ from_posture: false)
210
+ end
211
+
212
+ def build_postures(defaults)
213
+ defaults.to_h { |name, labels| [name.to_s, LabelSet.new(Array(labels).map(&:to_s))] }.freeze
214
+ end
215
+
216
+ def build_classes(classes)
217
+ classes.to_h { |name, body| [name.to_s, build_class(name.to_s, body || {})] }.freeze
218
+ end
219
+
220
+ def build_class(name, body)
221
+ mutators = resolve_mutators(name, body["mutators"])
222
+ ClassEntry.new(
223
+ instance_methods: build_rows(name, body["methods"], mutators),
224
+ singleton_methods: build_rows(name, body["singleton_methods"], nil),
225
+ posture_labels: resolve_posture(name, body["posture"]),
226
+ singleton_posture_labels: resolve_posture(name, body["singleton_posture"] || body["posture"]),
227
+ mutators: mutators,
228
+ object: body["kind"] == "object"
229
+ )
230
+ end
231
+
232
+ def resolve_posture(class_name, posture)
233
+ return LabelSet::EMPTY if posture.nil?
234
+
235
+ @postures[posture.to_s] ||
236
+ raise(Error, "#{class_name}: unknown posture #{posture.inspect}; add it to the catalogue's defaults:")
237
+ end
238
+
239
+ def resolve_mutators(class_name, named)
240
+ return NO_MUTATORS if named.nil?
241
+
242
+ set = MUTATOR_SETS[named.to_s] ||
243
+ raise(Error, "#{class_name}: unknown mutator set #{named.inspect}")
244
+ set.to_set(&:to_s).freeze
245
+ end
246
+
247
+ def build_rows(class_name, rows, mutators)
248
+ return EMPTY_ROWS if rows.nil?
249
+
250
+ rows.to_h do |name, body|
251
+ key = name.to_s
252
+ [key, build_row("#{class_name}##{key}", body || {}, mutators&.include?(key) == true)]
253
+ end.freeze
254
+ end
255
+
256
+ def build_row(key, body, in_mutator_set)
257
+ narrow = body["narrow"]&.to_s
258
+ raise Error, "#{key}: unknown narrowing handler #{narrow.inspect}" if narrow && !Narrowing.known?(narrow)
259
+ raise Error, "#{key}: every catalogue row needs a `why:` justification" if body["why"].to_s.empty?
260
+
261
+ labels = LabelSet.new(Array(body["effects"]).map(&:to_s))
262
+ mutates_receiver = body["mutates"].to_s == "receiver" || in_mutator_set
263
+ Row.new(
264
+ labels: labels,
265
+ narrow: narrow,
266
+ mutates_receiver: mutates_receiver,
267
+ entry: Entry.new(labels: labels, mutates_receiver: mutates_receiver, from_posture: false)
268
+ )
269
+ end
270
+
271
+ EMPTY_ROWS = {}.freeze
272
+ private_constant :EMPTY_ROWS
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+ require_relative "../inference/origin_lookup"
5
+ require_relative "attribution"
6
+ require_relative "envelope_index"
7
+ require_relative "file_collection"
8
+ require_relative "plugin_facts"
9
+ require_relative "scanner"
10
+
11
+ module Rigor
12
+ module Effects
13
+ # Records what the typer already decided about each call site, and turns one file's decisions into a
14
+ # {FileCollection} (ADR-103 WD13; the contract is `docs/internal-spec/effect-summaries.md`).
15
+ #
16
+ # Modelled on {Rigor::Analysis::DependencyRecorder}, for the same reason: the recording site sits on
17
+ # the per-dispatch hot path, so **the disabled fast path must be a plain integer read**. A
18
+ # module-level activation count answers {active?} without a `Thread.current` lookup; the per-thread
19
+ # accumulator isolates the actual recording, so a non-recording thread that sees `active?` true (a
20
+ # sibling thread is recording) pays one extra nil-check and nothing else.
21
+ #
22
+ # The recorder is **observational**. It never triggers inference, never resolves what the dispatcher
23
+ # declined, and never touches `Scope` — it reads the receiver type the typer computed anyway and the
24
+ # `dynamic_origins` cause the typer already recorded. With no `effects:` block nothing calls
25
+ # {collect_for}, `@active_count` stays zero, and `rigor check` is byte-identical.
26
+ module Collector
27
+ KEY = :__rigor_effects_collector__
28
+ private_constant :KEY
29
+
30
+ # What one call site decided, as the typer saw it.
31
+ #
32
+ # `receiver_class` / `kind` are the class the receiver's type projects to (`Nominal[Foo]` →
33
+ # `["Foo", :instance]`, `Singleton[Time]` → `["Time", :singleton]`), nil when it projects to none.
34
+ # `dynamic` marks a `Dynamic` receiver and `cause` carries the {Inference::DynamicOrigin} name
35
+ # behind it, which is the detail the `dynamic-receiver` taint reports. `resolved` is false only when
36
+ # every dispatch tier declined — the typer's own "nothing here resolved" verdict, which is what
37
+ # separates an unresolvable implicit-self call from an ordinary one.
38
+ CallRecord = Data.define(:receiver_class, :kind, :dynamic, :cause, :resolved)
39
+
40
+ # Per-file, per-thread accumulator. Not frozen and never shared: it lives for one `analyze_file`.
41
+ class Accumulator
42
+ attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts
43
+ attr_accessor :root
44
+
45
+ def initialize(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
46
+ plugin_facts: PluginFacts.empty)
47
+ @path = path
48
+ @attribution = attribution
49
+ @envelopes = envelopes
50
+ @plugin_facts = plugin_facts
51
+ # Node identity, not equality: two structurally identical call nodes in one file are different
52
+ # sites, and `Prism::Node#hash` is structural.
53
+ @calls = {}.compare_by_identity
54
+ @root = nil
55
+ end
56
+
57
+ # First write wins. The main typing pass records a site before any re-query does (`CheckRules`
58
+ # re-runs `type_of` over the same nodes, and a call-site-driven body re-type may visit a node a
59
+ # second time with a different receiver), so pinning the first decision keeps a file's record
60
+ # independent of how many times the node is visited — which is what makes pooled and sequential
61
+ # runs agree.
62
+ def record(node, call_record)
63
+ @calls[node] = call_record unless @calls.key?(node)
64
+ end
65
+
66
+ # Whether this node's decision is already pinned. Asked BEFORE the record is built, because the
67
+ # re-queries below are roughly half of all recording calls on a Rails app and building a record
68
+ # only to drop it is the whole of that half's cost.
69
+ def recorded?(node)
70
+ @calls.key?(node)
71
+ end
72
+
73
+ def mark_unresolved(node)
74
+ existing = @calls[node]
75
+ @calls[node] = existing.with(resolved: false) if existing
76
+ end
77
+ end
78
+
79
+ # Module-level activation count so {active?} is a plain integer read (GVL-atomic) rather than a
80
+ # `Thread.current` hash lookup.
81
+ @active_count = 0
82
+ @mutex = Mutex.new
83
+
84
+ module_function
85
+
86
+ # Runs `block` with collection active for `path` and returns the resulting {FileCollection}. Nests
87
+ # safely and restores the previous accumulator on exit.
88
+ #
89
+ # `attribution` is the project's `effects.attribution:` table (#385), carried on the window rather
90
+ # than read from a global: a worker process and the parent must scan under the same table, and the
91
+ # only thing that knows it is the configuration the run was built from. `envelopes` is the
92
+ # {EnvelopeIndex} of #386, carried the same way and for the same reason — the declared lane a call
93
+ # site imports must not depend on which process typed the file.
94
+ def collect_for(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
95
+ plugin_facts: PluginFacts.empty)
96
+ previous = Thread.current[KEY]
97
+ accumulator = Accumulator.new(path.to_s, attribution: attribution, envelopes: envelopes,
98
+ plugin_facts: plugin_facts)
99
+ Thread.current[KEY] = accumulator
100
+ @mutex.synchronize { @active_count += 1 }
101
+ yield
102
+ build(accumulator)
103
+ ensure
104
+ Thread.current[KEY] = previous
105
+ @mutex.synchronize { @active_count -= 1 }
106
+ end
107
+
108
+ def active?
109
+ @active_count.positive?
110
+ end
111
+
112
+ # The parsed root of the file being analyzed. Recorded by the analysis body once the parse is known
113
+ # to have succeeded, so the scan runs over exactly what the typer saw.
114
+ def record_root(root)
115
+ accumulator = Thread.current[KEY]
116
+ accumulator.root = root if accumulator && accumulator.root.nil?
117
+ end
118
+
119
+ # One call site's dispatch decision. `receiver` is the type the typer computed for the receiver and
120
+ # `scope` the scope it computed it in; nothing here asks either for more.
121
+ #
122
+ # The `scope.source_path` guard matters: inter-procedural inference re-types a *callee's* body while
123
+ # the caller's file is being analyzed, and those nodes belong to the callee's own file collection.
124
+ def record_call(node, receiver, scope)
125
+ accumulator = Thread.current[KEY]
126
+ return if accumulator.nil? || accumulator.path != scope.source_path
127
+ # First write wins, so a re-query has nothing to add — and asking here rather than inside
128
+ # {Accumulator#record} is what stops it paying for the `Data` and the origin lookup below.
129
+ return if accumulator.recorded?(node)
130
+
131
+ dynamic = receiver.is_a?(Type::Dynamic)
132
+ class_name, kind = descriptor_for(receiver)
133
+ accumulator.record(
134
+ node,
135
+ CallRecord.new(
136
+ receiver_class: class_name, kind: kind, dynamic: dynamic,
137
+ cause: dynamic ? Inference::OriginLookup.origin_for(scope, node.receiver)&.to_s : nil,
138
+ resolved: true
139
+ )
140
+ )
141
+ end
142
+
143
+ # The typer's fallback verdict: no tier resolved this call.
144
+ def record_unresolved(node, source_path)
145
+ accumulator = Thread.current[KEY]
146
+ return if accumulator.nil? || accumulator.path != source_path
147
+
148
+ accumulator.mark_unresolved(node)
149
+ end
150
+
151
+ # The class a receiver type projects to, as `[class_name, kind]`. A deliberately small mirror of the
152
+ # dispatcher's own receiver descriptor: enough shapes that a fresh `[]` reads as `Array` and a
153
+ # constant receiver as its singleton, and nothing more. An unmapped type simply has no class name,
154
+ # which costs an edge rather than producing a wrong one.
155
+ def descriptor_for(receiver)
156
+ case receiver
157
+ when Type::Nominal then [receiver.class_name, :instance]
158
+ when Type::Singleton then [receiver.class_name, :singleton]
159
+ when Type::Tuple then ["Array", :instance]
160
+ when Type::HashShape then ["Hash", :instance]
161
+ when Type::Constant then [receiver.value.class.name, :instance]
162
+ when Type::Dynamic then descriptor_for(receiver.static_facet)
163
+ end
164
+ end
165
+
166
+ # Fail-soft (WD13): the scan raising drops this file's summaries and never reaches `rigor check`.
167
+ def build(accumulator)
168
+ return FileCollection.empty(accumulator.path) if accumulator.root.nil?
169
+
170
+ Scanner.scan(
171
+ root: accumulator.root, path: accumulator.path, calls: accumulator.calls,
172
+ attribution: accumulator.attribution, envelopes: accumulator.envelopes,
173
+ plugin_facts: accumulator.plugin_facts
174
+ )
175
+ rescue StandardError
176
+ FileCollection.new(path: accumulator.path, failed: true)
177
+ end
178
+
179
+ private_class_method :build, :descriptor_for
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envelope"
4
+ require_relative "label_set"
5
+ require_relative "method_key"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Envelopes by **convention** — the `effects.envelopes:` block of `.rigor.yml` (ADR-103 WD5 (2);
10
+ # design note § 6.2).
11
+ #
12
+ # ```yaml
13
+ # effects:
14
+ # envelopes:
15
+ # - match: "app/presenters/**/*.rb" # File.fnmatch, project-relative — the ADR-28 shape
16
+ # effect: [] # the empty envelope: `pure`
17
+ # - namespace: "Policies::*"
18
+ # effect: [mutate.local]
19
+ # ```
20
+ #
21
+ # This is the surface that pays on day one for a project that writes no RBS: one stanza bounds a whole
22
+ # architectural layer. An entry attaches an envelope to every method of every class it selects and
23
+ # **distributes exactly as a class-level annotation does** — reopenings and synthesised `attr_*` /
24
+ # `define_method` members included, never subclasses (a subclass matches only if it matches on its own
25
+ # account).
26
+ #
27
+ # ## Selection
28
+ #
29
+ # - `match:` selects by the class's **defining file**. A class matches when *any* file that defines a
30
+ # method of it matches the glob — a class opened in `app/presenters/user.rb` and reopened in
31
+ # `lib/patch.rb` is a presenter. `File.fnmatch?` with `FNM_PATHNAME`, project-relative, so `**` is
32
+ # the only way across a directory boundary: the `unused --entry-point` and
33
+ # `effects.snapshot.reach:` semantics, spelled once ({.path_match?}).
34
+ # - `namespace:` selects by the class's **fully-qualified name**, segment by segment ({.namespace_match?}).
35
+ #
36
+ # ## Precedence
37
+ #
38
+ # Nearest wins, and configuration is the furthest thing from the method:
39
+ #
40
+ # per-method annotation > class-level annotation > config entry
41
+ #
42
+ # Among config entries, the **first** matching entry in file order wins — a list is read top to
43
+ # bottom, and a later entry never silently overrides one an author put above it. There is no merging:
44
+ # one method has at most one envelope, from exactly one source.
45
+ module ConfigEnvelopes
46
+ # What `location` carries for a configured envelope. Not a `path:line` — the loader cannot say which
47
+ # line the entry was written on — so it names the key path instead, which is what a reader greps for.
48
+ CONFIG_PATH = ".rigor.yml"
49
+
50
+ NO_ENVELOPES = {}.freeze
51
+ private_constant :NO_ENVELOPES
52
+
53
+ # One entry, resolved against the vocabulary.
54
+ #
55
+ # `bound` is {LabelSet::TOP} when some member of `effect:` is unrecognised — the fail-open rule, the
56
+ # same degradation an annotation carrying an unknown label takes — and `unknown_labels` is what
57
+ # `effect.unknown-label` reports off.
58
+ Entry = Data.define(:index, :match, :namespace, :bound, :labels, :unknown_labels) do
59
+ # `.rigor.yml effects.envelopes[2]` — how the `effect.envelope-exceeded` message names the source.
60
+ def location = "#{CONFIG_PATH} effects.envelopes[#{index}]"
61
+
62
+ # The bound quoted back the way the author wrote it, in the config's own spelling.
63
+ def spelling = "effect: [#{labels.join(', ')}]"
64
+
65
+ def top? = bound.top?
66
+ end
67
+
68
+ module_function
69
+
70
+ # Resolves `Configuration#effects_envelopes` against a registry.
71
+ #
72
+ # @param entries [Array<Hash>] the loaded, shape-validated entries
73
+ # @param registry [Registry] the vocabulary, project extensions included
74
+ # @return [Array<Entry>]
75
+ def build(entries:, registry:)
76
+ entries.each_with_index.map do |entry, index|
77
+ labels = Array(entry["effect"]).map(&:to_s)
78
+ unknown = labels.reject { |label| registry.known?(label) }
79
+ Entry.new(
80
+ index: index, match: entry["match"], namespace: entry["namespace"],
81
+ bound: unknown.empty? ? LabelSet.new(labels) : LabelSet::TOP,
82
+ labels: labels.freeze, unknown_labels: unknown.freeze
83
+ )
84
+ end.freeze
85
+ end
86
+
87
+ # The class-level envelopes the entries put on a project.
88
+ #
89
+ # @param entries [Array<Entry>]
90
+ # @param class_names [Enumerable<String>] every class the run collected units for
91
+ # @param sources [Hash{String => Array<String>}] `Runner#effect_sources` — `{method key => [path]}`
92
+ # @param project_root [String] what `sources` paths are relativised against
93
+ # @return [Hash{String => Envelope}] one envelope per selected class, keyed by class name
94
+ def for_classes(entries:, class_names:, sources: {}, project_root: Dir.pwd)
95
+ return NO_ENVELOPES if entries.empty?
96
+
97
+ files = files_by_class(sources, project_root)
98
+ class_names.each_with_object({}) do |class_name, out|
99
+ entry = entries.find { |candidate| selects?(candidate, class_name, files[class_name]) }
100
+ next if entry.nil?
101
+
102
+ out[class_name] = envelope_for(entry, class_name)
103
+ end
104
+ end
105
+
106
+ # The {Envelope} an entry puts on one class. Public because {EnvelopeIndex} resolves the same
107
+ # entries per *call site* rather than per project class ({.for_classes}'s shape), and the two
108
+ # must build the identical value: a bound that read differently at a call site and at the `def`
109
+ # would make the `≤` lane disagree with the check that enforces it.
110
+ def envelope_for(entry, class_name)
111
+ Envelope.build(
112
+ owner_key: class_name, bound: entry.bound, source: Envelope::CONFIG_SOURCE,
113
+ location: entry.location, spelling: entry.spelling,
114
+ unknown_labels: entry.unknown_labels, declared_labels: entry.labels
115
+ )
116
+ end
117
+
118
+ def selects?(entry, class_name, paths)
119
+ return namespace_match?(entry.namespace, class_name) if entry.namespace
120
+
121
+ Array(paths).any? { |path| path_match?(entry.match, path) }
122
+ end
123
+
124
+ # `File.fnmatch?` with `FNM_PATHNAME` over a project-relative path — the ADR-28 `path_glob` shape
125
+ # and the `unused --entry-point` one. `FNM_PATHNAME` is what makes `app/*/x.rb` stop at one
126
+ # directory and `**` the only way past it.
127
+ def path_match?(glob, path)
128
+ File.fnmatch?(glob, path, File::FNM_PATHNAME)
129
+ end
130
+
131
+ # A constant-path glob, matched **segment by segment** over the `::`-separated FQN:
132
+ #
133
+ # - a literal segment matches itself, and `*` inside one matches any run of characters *within* that
134
+ # segment (`Api::V*` matches `Api::V2`);
135
+ # - `*` alone matches exactly one segment — `Presenters::*` matches `Presenters::User` and NOT
136
+ # `Presenters::Admin::User`, nor bare `Presenters`;
137
+ # - `**` matches one or more consecutive segments — `Presenters::**` matches both `Presenters::User`
138
+ # and `Presenters::Admin::User`, and still not bare `Presenters`.
139
+ #
140
+ # Deliberately not `File.fnmatch` over a `/`-substituted name: the semantics above are the ones the
141
+ # documentation states, and borrowing a path matcher would make them depend on how one library
142
+ # happens to treat a trailing `**`.
143
+ def namespace_match?(glob, class_name)
144
+ match_segments?(glob.to_s.split("::"), class_name.to_s.split("::"))
145
+ end
146
+
147
+ def match_segments?(pattern, name)
148
+ return name.empty? if pattern.empty?
149
+
150
+ head, *rest = pattern
151
+ return match_deep?(rest, name) if head == "**"
152
+ return false if name.empty?
153
+ return false unless File.fnmatch?(head, name.first)
154
+
155
+ match_segments?(rest, name.drop(1))
156
+ end
157
+
158
+ # `**` consumes at least one segment, then the remaining pattern must match what is left. Bounded by
159
+ # the name's own depth, so the recursion is a handful of frames on any real constant path.
160
+ def match_deep?(rest, name)
161
+ (1..name.length).any? { |taken| match_segments?(rest, name.drop(taken)) }
162
+ end
163
+
164
+ # `{class name => [project-relative path]}`, from the per-unit source table. Sorted and de-duplicated
165
+ # so a glob decision does not depend on Hash order.
166
+ def files_by_class(sources, project_root)
167
+ root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
168
+ sources.each_with_object({}) do |(key, paths), out|
169
+ owner = MethodKey.owner(key)
170
+ next if owner.nil?
171
+
172
+ bucket = (out[owner] ||= [])
173
+ Array(paths).each { |path| bucket << relativize(path, root) }
174
+ end.each_value(&:uniq!)
175
+ end
176
+
177
+ def relativize(path, root)
178
+ absolute = File.absolute_path(path.to_s)
179
+ absolute.start_with?(root) ? absolute[root.length..] : path.to_s
180
+ end
181
+
182
+ private_class_method :selects?, :match_segments?, :match_deep?, :files_by_class, :relativize
183
+ end
184
+ end
185
+ end