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,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ require_relative "label"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # The effect-label vocabulary: which spellings are recognised, which have been retired, and who
10
+ # may open a new root (ADR-103 WD2; normative in
11
+ # `docs/type-specification/effect-labels.md`).
12
+ #
13
+ # The shared layer is hand-written in `data/effects/registry.yml` — Steins' v1 set verbatim,
14
+ # Ruby's `mutate` leaves, the proposed shared core leaves and the application-meaning roots —
15
+ # so a later slice or a plugin extends the data, not this Ruby. Extensions arrive through
16
+ # {#with}, which is the one place root ownership is enforced.
17
+ #
18
+ # A Registry is a frozen value object; {#with} returns a new one rather than mutating.
19
+ class Registry
20
+ class Error < StandardError
21
+ end
22
+
23
+ # An added label whose root neither exists already nor belongs to the extender.
24
+ class OwnershipError < Error
25
+ end
26
+
27
+ # An added label that is not well-formed under {Label::PATTERN}.
28
+ class InvalidLabelError < Error
29
+ end
30
+
31
+ DATA_PATH = File.expand_path("../../../data/effects/registry.yml", __dir__)
32
+
33
+ # How far a misspelling may be from a known label before {#suggest} declines to guess. Two
34
+ # edits catches a transposition or a dropped segment character ("io.nte", "nondet.tim")
35
+ # without proposing an unrelated label for a genuinely new spelling.
36
+ SUGGESTION_DISTANCE_CAP = 2
37
+
38
+ # The shared registry as shipped. Memoised: the YAML parse is a once-per-process cost, and
39
+ # nothing consumes the registry yet, so it stays lazy rather than paying at `require "rigor"`.
40
+ def self.default
41
+ @default ||= load_file(DATA_PATH)
42
+ end
43
+
44
+ # The vocabulary one run works in: the shipped registry plus whatever `effects.labels:` opened
45
+ # (ADR-103 WD2 / #385). The project is the one extender that may open ANY root — listing a label in
46
+ # its own configuration is the vouching act — so this can only fail on a spelling `Configuration`
47
+ # already rejected at load, and a failure degrades to the shipped vocabulary rather than taking the
48
+ # run down.
49
+ #
50
+ # Memoised on the label list, because the answer is a frozen value object and every effects surface
51
+ # in a run asks for the same one: the envelope pass, the unknown-label check, the snapshot header.
52
+ #
53
+ # `plugin_facts` (#387) folds in every loaded plugin's `effect_labels:` FIRST, each under its own
54
+ # owner, so that a project's `effects.labels:` can then name a plugin-opened root and an envelope may
55
+ # bound `rails.activejob.enqueue` without the project having to re-declare the framework's vocabulary.
56
+ # The plugin layer is not memoised on its own — {PluginFacts} is already per-process — and the
57
+ # project layer keeps its memo keyed on the pair.
58
+ def self.for_configuration(configuration, plugin_facts: nil)
59
+ base = plugin_facts.nil? || plugin_facts.empty? ? default : plugin_facts.extend_registry(default)
60
+ labels = configuration.effects_labels
61
+ return base if labels.nil? || labels.empty?
62
+
63
+ (@extended ||= {})[[labels, base.labels]] ||= base.with(labels: labels, owner: nil)
64
+ rescue Error
65
+ default
66
+ end
67
+
68
+ # Build a registry from a registry-shaped YAML file. Missing or unreadable data degrades to
69
+ # an empty vocabulary rather than raising, matching the built-in catalogues' posture for a
70
+ # bare install that opted data out; every label then reads as unknown, which is fail-open.
71
+ def self.load_file(path)
72
+ raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
73
+ raw = {} unless raw.is_a?(Hash)
74
+ new(
75
+ vocabulary_version: raw.fetch("vocabulary", 0),
76
+ labels: raw.fetch("labels", nil) || [],
77
+ retired: raw.fetch("retired", nil) || {}
78
+ )
79
+ end
80
+
81
+ attr_reader :vocabulary_version
82
+
83
+ def initialize(vocabulary_version:, labels:, retired: {})
84
+ @vocabulary_version = vocabulary_version
85
+ @labels = labels.map(&:to_s).uniq.sort.freeze
86
+ @known = build_known(@labels)
87
+ @roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
88
+ @retired = build_retired(retired)
89
+ freeze
90
+ end
91
+
92
+ # Every declared label, sorted. Implied ancestors (`email`, because `email.send` is declared)
93
+ # are recognised by {#known?} but are not rows of the vocabulary and are not listed here.
94
+ attr_reader :labels
95
+
96
+ # The roots of the vocabulary — the outermost segments {#with} treats as already owned.
97
+ attr_reader :roots
98
+
99
+ # Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
100
+ # `io` is recognised because `io.net` exists, so a bound may name an interior node the data
101
+ # file never spells out on its own line.
102
+ def known?(label)
103
+ @known.include?(label)
104
+ end
105
+
106
+ # The nearest recognised label to a misspelling, within {SUGGESTION_DISTANCE_CAP} edits, or
107
+ # `nil` when nothing is close enough. A recognised label suggests nothing — ask {#known?}
108
+ # first.
109
+ def suggest(label)
110
+ return nil unless Label.valid?(label)
111
+ return nil if known?(label)
112
+
113
+ best = nil
114
+ best_distance = SUGGESTION_DISTANCE_CAP + 1
115
+ @known.each do |candidate|
116
+ distance = levenshtein(label, candidate, best_distance)
117
+ next unless distance < best_distance
118
+
119
+ best = candidate
120
+ best_distance = distance
121
+ end
122
+ best
123
+ end
124
+
125
+ # The replacement labels for a retired spelling, or `nil` when the spelling was never
126
+ # retired. A rename or a removal bumps {#vocabulary_version} and records the old spelling
127
+ # here so a snapshot written by an older Rigor still reads.
128
+ def retired(label)
129
+ @retired[label]
130
+ end
131
+
132
+ # A new registry carrying `labels` on top of this one.
133
+ #
134
+ # `owner` is the identity opening a root: a plugin id, or — for a first-party plugin that
135
+ # models a framework — the framework root it owns. `nil` is the project, which may open any
136
+ # root. Every added label must either descend from a root this registry already knows or
137
+ # open a root equal to `owner`.
138
+ def with(labels:, owner:)
139
+ added = labels.map(&:to_s)
140
+ added.each do |label|
141
+ raise InvalidLabelError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
142
+
143
+ check_ownership(label, owner)
144
+ end
145
+ self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired)
146
+ end
147
+
148
+ private
149
+
150
+ def check_ownership(label, owner)
151
+ root = Label.root(label)
152
+ return if @roots.include?(root)
153
+ return if owner.nil?
154
+ return if owner == root
155
+
156
+ raise OwnershipError,
157
+ "#{owner.inspect} may not open the effect-label root #{root.inspect} (from #{label.inspect}); " \
158
+ "a non-project extender opens only the root it owns"
159
+ end
160
+
161
+ def build_known(labels)
162
+ known = labels.to_set
163
+ labels.each { |label| Label.ancestors(label).each { |ancestor| known << ancestor } }
164
+ known.freeze
165
+ end
166
+
167
+ def build_retired(retired)
168
+ retired.to_h { |spelling, replacements| [spelling.to_s, Array(replacements).map(&:to_s).freeze] }.freeze
169
+ end
170
+
171
+ # Bounded Levenshtein. Answers `cap + 1` — "further than the cap" — as soon as the length
172
+ # difference or a whole row of the matrix rules the candidate out, so scanning the vocabulary
173
+ # for a suggestion stays cheap.
174
+ def levenshtein(from, to, cap)
175
+ beyond = cap + 1
176
+ return beyond if (from.length - to.length).abs > cap
177
+
178
+ previous = (0..to.length).to_a
179
+ from.each_char.with_index(1) do |char, row|
180
+ current = [row]
181
+ to.each_char.with_index(1) do |other, column|
182
+ cost = char == other ? 0 : 1
183
+ current << [current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost].min
184
+ end
185
+ return beyond if current.min > cap
186
+
187
+ previous = current
188
+ end
189
+ previous.last > cap ? beyond : previous.last
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,290 @@
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
+ instance_method = !singleton && node.receiver.nil?
120
+ harvest_for(prefix)[:defs] << node.name.to_s if @harvest && instance_method && !prefix.empty?
121
+ add_unit(
122
+ class_name_for(prefix), node.name.to_s, singleton || !node.receiver.nil?,
123
+ node.body, node.parameters
124
+ )
125
+ end
126
+
127
+ # ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
128
+ # arguments]`. That is all the framework-edge strategies need: `before_save :normalize` names a
129
+ # method on the same class, and a computed callback (`before_save -> { … }`, a method object) names
130
+ # none the strategy could resolve, so it contributes nothing rather than a guess. The block form is
131
+ # already contained in the class body, which v1 does not model as a unit at all.
132
+ def harvest_class_body_macro(node, prefix)
133
+ return if @harvest.nil? || prefix.empty? || !node.receiver.nil?
134
+
135
+ entry = harvest_for(prefix)
136
+ name = node.name.to_s
137
+ if FrameworkUnits::CALLBACK_MACROS.include?(name)
138
+ (entry[:macros][name] ||= []).concat(symbol_arguments(node))
139
+ elsif uniqueness_validator?(node, name)
140
+ entry[:uniqueness] = true
141
+ end
142
+ end
143
+
144
+ # `validates :email, uniqueness: true` and `validates_uniqueness_of :email` — the validation whose
145
+ # implementation is a `SELECT`.
146
+ def uniqueness_validator?(node, name)
147
+ return true if name == FrameworkUnits::UNIQUENESS_MACRO
148
+ return false unless name == FrameworkUnits::VALIDATES_MACRO
149
+
150
+ node.arguments&.arguments&.any? do |argument|
151
+ argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
152
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
153
+ element.key.unescaped == FrameworkUnits::UNIQUENESS_OPTION
154
+ end
155
+ end || false
156
+ end
157
+
158
+ def harvest_for(prefix)
159
+ @harvest[class_name_for(prefix)] ||= { defs: [], macros: {}, uniqueness: false }
160
+ end
161
+
162
+ # Files the units the framework contributes for each class this file declares. Runs after the walk,
163
+ # because a callback macro may be written below the `def` it names and a mailer action may be
164
+ # declared anywhere in the body.
165
+ def synthesize_framework_units
166
+ return if @harvest.nil?
167
+
168
+ @harvest.each do |class_name, entry|
169
+ FrameworkUnits.synthesize(
170
+ class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
171
+ uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts
172
+ ).each { |key, summary, edges| merge_unit(key, summary, edges) }
173
+ end
174
+ end
175
+
176
+ # Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
177
+ # per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
178
+ # `collector-error` and its siblings are unaffected.
179
+ def add_unit(class_name, method_name, singleton, body, parameters)
180
+ key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
181
+ names = parameter_names(parameters)
182
+ scan = UnitScan.new(
183
+ singleton: singleton, parameters: names,
184
+ block_parameter: block_parameter_name(parameters),
185
+ owned_locals: LocalOwnership.owned(body, names), calls: @calls,
186
+ attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
187
+ owner_class: class_name
188
+ )
189
+ summary, edges = scan.run(body)
190
+ merge_unit(key, summary, edges)
191
+ scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
192
+ add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
193
+ end
194
+ rescue StandardError
195
+ merge_unit(key, Summary.tainted("collector-error", method_name), [])
196
+ end
197
+
198
+ # A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
199
+ # not themselves effect units in v1 (their statements run at load time), so nothing else in one
200
+ # contributes labels — but `include` and the accessor macros decide what the *methods* are.
201
+ def declaration?(node)
202
+ node.receiver.nil? && DECLARATION_MACROS.include?(node.name)
203
+ end
204
+
205
+ def record_declaration(node, prefix)
206
+ class_name = class_name_for(prefix)
207
+ case node.name
208
+ when :include, :prepend then record_includes(class_name, node, prefix)
209
+ when :define_method then declare_define_method(class_name, node)
210
+ else synthesize_accessors(class_name, node)
211
+ end
212
+ end
213
+
214
+ def declare_define_method(class_name, node)
215
+ unit = UnitScan.define_method_unit(node)
216
+ return if unit.nil?
217
+
218
+ name, singleton, body, parameters = unit
219
+ add_unit(class_name, name, singleton, body, parameters)
220
+ end
221
+
222
+ def synthesize_accessors(class_name, node)
223
+ symbol_arguments(node).each do |name|
224
+ merge_unit("#{class_name}##{name}", Summary.empty, []) unless node.name == :attr_writer
225
+ next if node.name == :attr_reader
226
+
227
+ merge_unit("#{class_name}##{name}=", WRITER_SUMMARY, [])
228
+ end
229
+ end
230
+
231
+ def record_includes(class_name, node, prefix)
232
+ names = constant_arguments(node).flat_map { |name| lexical_candidates(name, prefix) }
233
+ (@includes[class_name] ||= []).concat(names) unless names.empty?
234
+ end
235
+
236
+ def record_superclass(full_name, node, prefix)
237
+ superclass = node.superclass && Source::ConstantPath.qualified_name(node.superclass)
238
+ @superclasses[full_name] = lexical_candidates(superclass, prefix) if superclass
239
+ end
240
+
241
+ # An ancestry name is recorded AS WRITTEN — `class Loud < Base` inside `module Tracer` names
242
+ # `Base`, not `Tracer::Base` — and a single file cannot say which constant that resolves to. So the
243
+ # scanner records the candidates Ruby's own lexical lookup would try, most-qualified first, and the
244
+ # propagator picks the one the merged project actually defines. Same shape as `ScopeIndexer`'s
245
+ # as-written superclass table, resolved at the same point: when the whole project is in view.
246
+ def lexical_candidates(name, prefix)
247
+ return [name] if prefix.empty? || name.start_with?("#{prefix.join('::')}::")
248
+
249
+ prefix.length.downto(1).map { |depth| "#{prefix.first(depth).join('::')}::#{name}" } + [name]
250
+ end
251
+
252
+ def merge_unit(key, summary, edges)
253
+ @summaries[key] = @summaries.key?(key) ? @summaries[key].join(summary) : summary
254
+ (@edges[key] ||= []).concat(edges) unless edges.empty?
255
+ end
256
+
257
+ def class_name_for(prefix)
258
+ prefix.empty? ? TOP_LEVEL_KEY : prefix.join("::")
259
+ end
260
+
261
+ def symbol_arguments(node)
262
+ node.arguments&.arguments&.filter_map { |argument| argument.unescaped if argument.is_a?(Prism::SymbolNode) } ||
263
+ []
264
+ end
265
+
266
+ def constant_arguments(node)
267
+ node.arguments&.arguments&.filter_map { |argument| Source::ConstantPath.qualified_name(argument) } || []
268
+ end
269
+
270
+ def parameter_names(parameters)
271
+ return Set.new unless parameters.is_a?(Prism::ParametersNode)
272
+
273
+ names = Set.new
274
+ [parameters.requireds, parameters.optionals, parameters.posts, parameters.keywords].each do |group|
275
+ group.each { |parameter| names << parameter.name.to_s if parameter.respond_to?(:name) && parameter.name }
276
+ end
277
+ [parameters.rest, parameters.keyword_rest].each do |parameter|
278
+ names << parameter.name.to_s if parameter.respond_to?(:name) && parameter&.name
279
+ end
280
+ names
281
+ end
282
+
283
+ def block_parameter_name(parameters)
284
+ return nil unless parameters.is_a?(Prism::ParametersNode)
285
+
286
+ parameters.block&.name&.to_s
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,74 @@
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 `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
62
+ # 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
63
+ # caller re-anchors it against the Ruby file when that matters.
64
+ def first_annotated(sources)
65
+ sources.each do |name, content|
66
+ content.each_line.with_index(1) do |line, number|
67
+ return [name, content, number] if ANNOTATION_HINT.match?(line)
68
+ end
69
+ end
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end