rigortype 0.3.3 → 0.3.5

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 (171) 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 +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  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 +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/constant_path"
6
+ require_relative "../source/node_children"
7
+ require_relative "attribution"
8
+ require_relative "envelope_index"
9
+ require_relative "file_collection"
10
+ require_relative "local_ownership"
11
+ require_relative "origin"
12
+ require_relative "framework_units"
13
+ require_relative "plugin_facts"
14
+ require_relative "summary"
15
+ require_relative "unit_scan"
16
+
17
+ module Rigor
18
+ module Effects
19
+ # Turns one file's AST plus the call decisions the typer recorded for it into a {FileCollection}.
20
+ #
21
+ # The scanner owns **identity** — which effect units the file defines and what each is keyed as — and
22
+ # delegates each unit's body to {UnitScan}. Keys follow the existing symbol tables (ADR-103 WD14):
23
+ # `Class#m` for an instance method, `Class.m` for a singleton one, and `<toplevel>#m` for a `def`
24
+ # outside any class body. Reopenings in one file join here; reopenings across files join when the
25
+ # runner merges the collections.
26
+ #
27
+ # Three kinds of unit exist beyond a plain `def`:
28
+ #
29
+ # - `define_method(:literal) { … }` — the block is the method's body. The def-node discovery tables
30
+ # skip it, so this is the minimal extension WD14 calls for, made here rather than in `ScopeIndexer`
31
+ # because nothing outside effects needs it yet.
32
+ # - `attr_reader` / `attr_writer` / `attr_accessor` — synthesised: a reader is ∅, a writer is
33
+ # `mutate.self`. Without them a caller's edge into an accessor would read as unresolved.
34
+ # - a nested `def` — its own unit under the same class, never contained in the enclosing method.
35
+ #
36
+ # **This walk exists only when collection is on.** ADR-103 WD13 prefers riding `ScopeIndexer`'s
37
+ # existing `def` descent; a separate walk is taken here because the scanner must also attribute each
38
+ # recorded call *node* to its enclosing unit, and doing that inside the indexer would put an
39
+ # effects-shaped concern on the hot path for every run. Off, this file is never loaded past `require`.
40
+ class Scanner
41
+ # Mirrors `Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY`. Spelled again rather than required so the
42
+ # effects namespace does not pull the indexer in; the two are pinned together by spec.
43
+ TOP_LEVEL_KEY = "<toplevel>"
44
+
45
+ # Receiver-less calls in a class / module body that {#record_declaration} interprets. Everything
46
+ # else in a class body is out of scope in v1 — its statements run at load time, which is a unit of
47
+ # its own that no slice models yet.
48
+ DECLARATION_MACROS = %i[include prepend attr_reader attr_writer attr_accessor define_method].to_set.freeze
49
+
50
+ MUTATE_SELF = LabelSet.new(["mutate.self"])
51
+ private_constant :MUTATE_SELF
52
+
53
+ # A synthesised writer's summary is the same value at every `attr_accessor` in the project, so it is
54
+ # built once rather than per accessor.
55
+ WRITER_SUMMARY = Summary.new(bundles: { Origin.construct("attr-writer") => MUTATE_SELF })
56
+ private_constant :WRITER_SUMMARY
57
+
58
+ def self.scan(root:, path:, calls:, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
59
+ plugin_facts: PluginFacts.empty)
60
+ new(path: path, calls: calls, attribution: attribution, envelopes: envelopes,
61
+ plugin_facts: plugin_facts).scan(root)
62
+ end
63
+
64
+ def initialize(path:, calls:, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
65
+ plugin_facts: PluginFacts.empty)
66
+ @path = path
67
+ @calls = calls
68
+ @attribution = attribution
69
+ @envelopes = envelopes
70
+ @plugin_facts = plugin_facts
71
+ @summaries = {}
72
+ @edges = {}
73
+ @superclasses = {}
74
+ @includes = {}
75
+ # ADR-103 WD10 / #387 — the class-body facts the framework-edge strategies read, harvested only
76
+ # when a loaded plugin declared one. A run with no `effect_edges:` never allocates them.
77
+ @harvest = plugin_facts.edges? ? {} : nil
78
+ end
79
+
80
+ def scan(root)
81
+ walk(root, [], false)
82
+ synthesize_framework_units
83
+ FileCollection.new(
84
+ path: @path, summaries: @summaries, edges: @edges,
85
+ superclasses: @superclasses, includes: @includes
86
+ )
87
+ end
88
+
89
+ private
90
+
91
+ def walk(node, prefix, singleton)
92
+ return unless node.is_a?(Prism::Node)
93
+
94
+ case node
95
+ when Prism::ClassNode, Prism::ModuleNode
96
+ return walk_namespace(node, prefix)
97
+ when Prism::SingletonClassNode
98
+ return walk(node.body, prefix, true) if node.body
99
+ when Prism::DefNode
100
+ return enter_def(node, prefix, singleton)
101
+ when Prism::CallNode
102
+ harvest_class_body_macro(node, prefix)
103
+ return record_declaration(node, prefix) if declaration?(node)
104
+ end
105
+
106
+ node.rigor_each_child { |child| walk(child, prefix, singleton) }
107
+ end
108
+
109
+ def walk_namespace(node, prefix)
110
+ name = Source::ConstantPath.qualified_name(node.constant_path)
111
+ return node.rigor_each_child { |child| walk(child, prefix, false) } if name.nil?
112
+
113
+ nested = prefix + [name]
114
+ record_superclass(nested.join("::"), node, prefix) if node.is_a?(Prism::ClassNode)
115
+ walk(node.body, nested, false) if node.body
116
+ end
117
+
118
+ def enter_def(node, prefix, singleton)
119
+ own_singleton = singleton || !node.receiver.nil?
120
+ scan = add_unit(class_name_for(prefix), node.name.to_s, own_singleton, node.body, node.parameters)
121
+ harvest_def(prefix, node.name.to_s, own_singleton, scan) if @harvest && !prefix.empty?
122
+ end
123
+
124
+ # What the framework strategies need to know about a `def` the class body spelled out itself: that it
125
+ # exists, and whether it reaches `super`. `:defs` stays instance-only, because a mailer action is an
126
+ # instance method; `:units` is keyed by the suffix a synthetic unit key carries, so both sides of the
127
+ # `#` / `.` split are answerable (#440). A body the collector could not finish reads as delegating,
128
+ # which keeps the framework's claim — the fail-soft direction for an upper bound.
129
+ def harvest_def(prefix, name, singleton, scan)
130
+ entry = harvest_for(prefix)
131
+ entry[:defs] << name unless singleton
132
+ entry[:units]["#{singleton ? '.' : '#'}#{name}"] = scan.nil? || scan.delegates_upward?
133
+ end
134
+
135
+ # ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
136
+ # arguments]`. That is all the framework-edge strategies need: `before_save :normalize` names a
137
+ # method on the same class, and a computed callback (`before_save -> { … }`, a method object) names
138
+ # none the strategy could resolve, so it contributes nothing rather than a guess. The block form is
139
+ # already contained in the class body, which v1 does not model as a unit at all.
140
+ def harvest_class_body_macro(node, prefix)
141
+ return if @harvest.nil? || prefix.empty? || !node.receiver.nil?
142
+
143
+ entry = harvest_for(prefix)
144
+ name = node.name.to_s
145
+ if FrameworkUnits::CALLBACK_MACROS.include?(name)
146
+ (entry[:macros][name] ||= []).concat(symbol_arguments(node))
147
+ elsif uniqueness_validator?(node, name)
148
+ entry[:uniqueness] = true
149
+ end
150
+ end
151
+
152
+ # `validates :email, uniqueness: true` and `validates_uniqueness_of :email` — the validation whose
153
+ # implementation is a `SELECT`.
154
+ def uniqueness_validator?(node, name)
155
+ return true if name == FrameworkUnits::UNIQUENESS_MACRO
156
+ return false unless name == FrameworkUnits::VALIDATES_MACRO
157
+
158
+ node.arguments&.arguments&.any? do |argument|
159
+ argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
160
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
161
+ element.key.unescaped == FrameworkUnits::UNIQUENESS_OPTION
162
+ end
163
+ end || false
164
+ end
165
+
166
+ def harvest_for(prefix)
167
+ @harvest[class_name_for(prefix)] ||= { defs: [], units: {}, macros: {}, uniqueness: false }
168
+ end
169
+
170
+ # Files the units the framework contributes for each class this file declares. Runs after the walk,
171
+ # because a callback macro may be written below the `def` it names and a mailer action may be
172
+ # declared anywhere in the body.
173
+ def synthesize_framework_units
174
+ return if @harvest.nil?
175
+
176
+ @harvest.each do |class_name, entry|
177
+ FrameworkUnits.synthesize(
178
+ class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
179
+ uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts, own_units: entry[:units]
180
+ ).each { |key, summary, edges| merge_unit(key, summary, edges) }
181
+ end
182
+ end
183
+
184
+ # Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
185
+ # per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
186
+ # `collector-error` and its siblings are unaffected.
187
+ #
188
+ # @return [UnitScan, nil] the finished scan, or nil when the unit failed soft
189
+ def add_unit(class_name, method_name, singleton, body, parameters)
190
+ key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
191
+ names = parameter_names(parameters)
192
+ scan = UnitScan.new(
193
+ singleton: singleton, parameters: names,
194
+ block_parameter: block_parameter_name(parameters),
195
+ owned_locals: LocalOwnership.owned(body, names), calls: @calls,
196
+ attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
197
+ owner_class: class_name, method_name: method_name
198
+ )
199
+ summary, edges = scan.run(body)
200
+ merge_unit(key, summary, edges)
201
+ scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
202
+ add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
203
+ end
204
+ scan
205
+ rescue StandardError
206
+ merge_unit(key, Summary.tainted("collector-error", method_name), [])
207
+ nil
208
+ end
209
+
210
+ # A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
211
+ # not themselves effect units in v1 (their statements run at load time), so nothing else in one
212
+ # contributes labels — but `include` and the accessor macros decide what the *methods* are.
213
+ def declaration?(node)
214
+ node.receiver.nil? && DECLARATION_MACROS.include?(node.name)
215
+ end
216
+
217
+ def record_declaration(node, prefix)
218
+ class_name = class_name_for(prefix)
219
+ case node.name
220
+ when :include, :prepend then record_includes(class_name, node, prefix)
221
+ when :define_method then declare_define_method(class_name, node)
222
+ else synthesize_accessors(class_name, node)
223
+ end
224
+ end
225
+
226
+ def declare_define_method(class_name, node)
227
+ unit = UnitScan.define_method_unit(node)
228
+ return if unit.nil?
229
+
230
+ name, singleton, body, parameters = unit
231
+ add_unit(class_name, name, singleton, body, parameters)
232
+ end
233
+
234
+ def synthesize_accessors(class_name, node)
235
+ symbol_arguments(node).each do |name|
236
+ merge_unit("#{class_name}##{name}", Summary.empty, []) unless node.name == :attr_writer
237
+ next if node.name == :attr_reader
238
+
239
+ merge_unit("#{class_name}##{name}=", WRITER_SUMMARY, [])
240
+ end
241
+ end
242
+
243
+ def record_includes(class_name, node, prefix)
244
+ names = constant_arguments(node).flat_map { |name| lexical_candidates(name, prefix) }
245
+ (@includes[class_name] ||= []).concat(names) unless names.empty?
246
+ end
247
+
248
+ def record_superclass(full_name, node, prefix)
249
+ superclass = node.superclass && Source::ConstantPath.qualified_name(node.superclass)
250
+ @superclasses[full_name] = lexical_candidates(superclass, prefix) if superclass
251
+ end
252
+
253
+ # An ancestry name is recorded AS WRITTEN — `class Loud < Base` inside `module Tracer` names
254
+ # `Base`, not `Tracer::Base` — and a single file cannot say which constant that resolves to. So the
255
+ # scanner records the candidates Ruby's own lexical lookup would try, most-qualified first, and the
256
+ # propagator picks the one the merged project actually defines. Same shape as `ScopeIndexer`'s
257
+ # as-written superclass table, resolved at the same point: when the whole project is in view.
258
+ def lexical_candidates(name, prefix)
259
+ return [name] if prefix.empty? || name.start_with?("#{prefix.join('::')}::")
260
+
261
+ prefix.length.downto(1).map { |depth| "#{prefix.first(depth).join('::')}::#{name}" } + [name]
262
+ end
263
+
264
+ def merge_unit(key, summary, edges)
265
+ @summaries[key] = @summaries.key?(key) ? @summaries[key].join(summary) : summary
266
+ (@edges[key] ||= []).concat(edges) unless edges.empty?
267
+ end
268
+
269
+ def class_name_for(prefix)
270
+ prefix.empty? ? TOP_LEVEL_KEY : prefix.join("::")
271
+ end
272
+
273
+ def symbol_arguments(node)
274
+ node.arguments&.arguments&.filter_map { |argument| argument.unescaped if argument.is_a?(Prism::SymbolNode) } ||
275
+ []
276
+ end
277
+
278
+ def constant_arguments(node)
279
+ node.arguments&.arguments&.filter_map { |argument| Source::ConstantPath.qualified_name(argument) } || []
280
+ end
281
+
282
+ def parameter_names(parameters)
283
+ return Set.new unless parameters.is_a?(Prism::ParametersNode)
284
+
285
+ names = Set.new
286
+ [parameters.requireds, parameters.optionals, parameters.posts, parameters.keywords].each do |group|
287
+ group.each { |parameter| names << parameter.name.to_s if parameter.respond_to?(:name) && parameter.name }
288
+ end
289
+ [parameters.rest, parameters.keyword_rest].each do |parameter|
290
+ names << parameter.name.to_s if parameter.respond_to?(:name) && parameter&.name
291
+ end
292
+ names
293
+ end
294
+
295
+ def block_parameter_name(parameters)
296
+ return nil unless parameters.is_a?(Prism::ParametersNode)
297
+
298
+ parameters.block&.name&.to_s
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # Everything an effect envelope may be written in, as `[buffer name, RBS source]` pairs
6
+ # (ADR-103 WD5 / WD6; #383 / #384).
7
+ #
8
+ # Two strata, and the exclusion is the point: the project's own `signature_paths:` tree (or the
9
+ # `sig/` default `Environment.for_project` falls back to) plus the loader's **virtual** entries —
10
+ # which is how rbs-inline's `# @rbs %a{…}` comments and a plugin's `source_rbs` synthesis arrive.
11
+ # Gem-shipped and bundled RBS is deliberately absent: WD6 makes project-authored envelopes the
12
+ # checked stratum, so a `%a{pure}` in rbs core cannot bound a project method that shares its key.
13
+ #
14
+ # Shared by the two passes that read declarations — the envelope check and the
15
+ # annotations-unchecked residual — so the residual can never disagree with the check about what
16
+ # counts as "the project's own signatures".
17
+ module SignatureSources
18
+ # Where `Environment.for_project` looks when no `signature_paths:` is configured. Spelled again
19
+ # here rather than required, because the constant is private to {Rigor::Environment} and a walk
20
+ # over the project's own files has no business reaching into its builder.
21
+ DEFAULT_ROOTS = ["sig"].freeze
22
+
23
+ # A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
24
+ # the two spellings the envelope reader honours and nothing else, so a signature tree with no
25
+ # effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
26
+ # not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
27
+ ANNOTATION_HINT = /%a\{\s*(?:pure\s*\}|rigor:v1:effect\b)/
28
+
29
+ # A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
30
+ # a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
31
+ # synthetic prefix comes off before the name is made project-relative.
32
+ VIRTUAL_BUFFER_PATTERN = /\Avirtual:[^:]*:/
33
+
34
+ module_function
35
+
36
+ # The path a reader can open, for a buffer name from {.collect}: the `.rbs` file as configured,
37
+ # or the `.rb` file behind a `virtual:` buffer, project-relative either way.
38
+ def source_path(name)
39
+ stripped = name.to_s.sub(VIRTUAL_BUFFER_PATTERN, "")
40
+ root = "#{Dir.pwd}#{File::SEPARATOR}"
41
+ stripped.start_with?(root) ? stripped[root.length..] : stripped
42
+ end
43
+
44
+ # @param signature_paths [Array<String>, nil] the configured roots; empty / nil takes the default.
45
+ # @param virtual_rbs [Array<Array(String, String)>, nil] the loader's virtual entries.
46
+ # @return [Array<Array(String, String)>]
47
+ def collect(signature_paths:, virtual_rbs: nil)
48
+ files = roots(signature_paths).flat_map { |root| Dir.glob(File.join(root.to_s, "**", "*.rbs")).sort }
49
+ sources = files.filter_map do |path|
50
+ [path, File.read(path)]
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ sources + Array(virtual_rbs).map { |name, content| [name.to_s, content.to_s] }
55
+ end
56
+
57
+ def roots(signature_paths)
58
+ signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
59
+ end
60
+
61
+ # The FIRST annotated virtual entry, as a `collect`-shaped one-element array (or an empty one).
62
+ #
63
+ # #441 — the analysis paths use this to hand the annotations-unchecked residual the inline stratum
64
+ # of a run that resolved an environment but never stored it. Reduced to one entry on purpose: the
65
+ # residual reports one annotation per run, so the carrier a run holds past the environment's own
66
+ # lifetime is a single synthesized buffer rather than the project's whole virtual tree.
67
+ #
68
+ # @param virtual_rbs [Array<Array(String, String)>, nil]
69
+ # @return [Array<Array(String, String)>] zero or one pair.
70
+ def annotated_carrier(virtual_rbs)
71
+ Array(virtual_rbs).each do |name, content|
72
+ return [[name.to_s, content.to_s]] if ANNOTATION_HINT.match?(content)
73
+ end
74
+ [].freeze
75
+ end
76
+
77
+ # The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
78
+ # 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
79
+ # caller re-anchors it against the Ruby file when that matters.
80
+ def first_annotated(sources)
81
+ sources.each do |name, content|
82
+ content.each_line.with_index(1) do |line, number|
83
+ return [name, content, number] if ANNOTATION_HINT.match?(line)
84
+ end
85
+ end
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end