rigortype 0.3.3 → 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 (158) 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 +6 -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/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One row of a plugin's `effect_attributions:` — "a call to this receiver's method contributes these
6
+ # effect labels" (ADR-103 WD6 / WD10; design note § 6.6; the contract is
7
+ # `docs/internal-spec/plugin.md` § Effect contributions).
8
+ #
9
+ # A framework method has no body Rigor reads, so someone must colour it. Three channels can, in
10
+ # descending order of preference:
11
+ #
12
+ # 1. a `%a{rigor:v1:effect …}` annotation in the plugin's own `signature_paths:` RBS — tier 1, read
13
+ # through {Rigor::Effects::EnvelopeIndex}'s accepted stratum, and the right channel whenever the
14
+ # plugin already ships a signature for the method;
15
+ # 2. this field, for the methods RBS cannot name per app — association readers, `find_by_*`, scopes —
16
+ # and for classes the plugin ships no RBS for at all;
17
+ # 3. the project's own `effects.attribution:` table, which is the user's answer for everything left.
18
+ #
19
+ # ## The receiver
20
+ #
21
+ # `receiver` is spelled one of two ways, and the spelling picks the matching rule:
22
+ #
23
+ # - a **class name** (`"ActiveRecord::Base"`, `"I18n"`) matches the class the call's receiver
24
+ # projects to, **through the project's inheritance chain**: an `ActiveRecord::Base` row applies to
25
+ # `User.find` because the project declares `User < ApplicationRecord < ActiveRecord::Base`. This is
26
+ # the difference from a catalogue row, which matches its exact owner and nothing else — Ruby's core
27
+ # classes are leaves in practice and a framework's base class never is.
28
+ # - a **receiver path** (`"Rails.cache"`, `"Time.zone"`, `"Rails.application.credentials"`) matches
29
+ # the receiver *expression* as the syntax spells it. `Rails.cache.read` has no receiver class the
30
+ # typer can name — `Rails.cache` is a call, and its return type is adapter-dependent by design —
31
+ # so the only honest handle on it is the path that was written.
32
+ # - a **self path** (`"self.session"`, `"self.flash.now"`, `"self.cookies.encrypted"`) is the same
33
+ # thing rooted at implicit self, and is what a Rails controller's accessors actually look like:
34
+ # `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`. A self-path row MUST
35
+ # name a `within:` class, because a receiver-less `session` in some other project class is a
36
+ # different `session` — the row applies only inside a class whose project ancestry reaches `within`.
37
+ #
38
+ # `on_result: true` shifts a class-name row one link outwards: it matches a call on **what a call to
39
+ # that class returned**. `UserMailer.welcome(u).deliver_now` and `WelcomeJob.set(wait: 1.hour)
40
+ # .perform_later` are the two idioms that need it — the object in the middle is a lazy
41
+ # `MessageDelivery` / `ConfiguredJob` whose type nothing in the project declares, while the class that
42
+ # produced it is written right there in the source. Without it the send and the enqueue, the two calls
43
+ # a reviewer most wants coloured, would go unattributed in the spelling Rails actually uses.
44
+ #
45
+ # ## Discharge
46
+ #
47
+ # `discharge: true` says the label is derived from the framework's own semantics rather than guessed,
48
+ # so the site is exhaustive rather than tainted. ADR-103 WD6 grants that only to a **first-party
49
+ # bundled** plugin ({FirstParty}), gated by `make check-plugins`; a third-party plugin's `true` is
50
+ # ignored with a load-time warning and the row behaves like the project's `effects.attribution:`
51
+ # table — declared, and carrying a `plugin-attribution` taint.
52
+ #
53
+ # Either way the labels land in the **declared** lane, never the proven one. A discharging row is a
54
+ # trusted claim, exactly like an accepted signature's `%a{…}`: "this is what it does", not "the
55
+ # analyzer read the body and saw this".
56
+ class EffectAttribution
57
+ # A receiver spelled as a `Constant::Path` — the class-name form. Anything else with a `.` in it is
58
+ # read as a receiver path.
59
+ CLASS_NAME = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/
60
+
61
+ # A receiver path: a constant head followed by one or more sends (`Rails.cache`,
62
+ # `Rails.application.credentials`).
63
+ RECEIVER_PATH = /\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*(\.[a-z_][A-Za-z0-9_]*)+\z/
64
+
65
+ # A self path: implicit self followed by one or more receiver-less sends (`self.flash.now`).
66
+ SELF_PATH = /\Aself(\.[a-z_][A-Za-z0-9_]*)+\z/
67
+
68
+ # What a self path is rooted at, once the `self.` head is stripped.
69
+ SELF_HEAD = "self"
70
+
71
+ attr_reader :receiver, :method, :singleton, :labels, :narrow, :discharge, :within, :on_result,
72
+ :taint, :why
73
+
74
+ # The taint causes a plugin row may name. A closed subset of
75
+ # {Rigor::Effects::TaintCause::ALL}: a plugin may say "and there is more here I cannot see", but
76
+ # only for the two reasons a framework model can honestly have — a template it does not read, and a
77
+ # callable whose body is supplied by the application.
78
+ TAINT_CAUSES = %w[template-not-analysed opaque-callable].freeze
79
+
80
+ # @param receiver [String] a class name or a receiver path (see above)
81
+ # @param method [Symbol, String] the selector this row colours
82
+ # @param singleton [Boolean] whether the row is `Receiver.method` rather than `Receiver#method`.
83
+ # Meaningless — and ignored — for a receiver path, whose head already fixes the receiver object.
84
+ # @param labels [Array<String>] the effect labels the call contributes
85
+ # @param narrow [String, nil] a {Rigor::Effects::Narrowing} handler name, when the call's own
86
+ # argument literals settle a question the row cannot (`connection.execute("SELECT …")`)
87
+ # @param discharge [Boolean] see above; honoured only for a first-party bundled plugin
88
+ # @param why [String] the audit justification, required exactly as `data/effects/core.yml` requires
89
+ # one of every row: a label with no stated reason is a claim nobody can review.
90
+ def initialize(receiver:, method:, labels:, why:, singleton: false, narrow: nil, discharge: false, # rubocop:disable Metrics/ParameterLists
91
+ within: nil, on_result: false, taint: nil)
92
+ @receiver = validate_receiver!(receiver)
93
+ @method = method.to_sym
94
+ @singleton = singleton ? true : false
95
+ @labels = normalize_labels(labels)
96
+ @narrow = narrow.nil? ? nil : narrow.to_s.dup.freeze
97
+ @discharge = discharge ? true : false
98
+ @within = validate_within!(within)
99
+ @on_result = on_result ? true : false
100
+ validate_on_result!
101
+ @taint = validate_taint!(taint)
102
+ @why = validate_why!(why)
103
+ freeze
104
+ end
105
+
106
+ # Whether {#receiver} is a receiver path (`Rails.cache`) rather than a class name or a self path.
107
+ def receiver_path?
108
+ @receiver.include?(".") && !self_path?
109
+ end
110
+
111
+ # Whether {#receiver} is a self path (`self.flash.now`).
112
+ def self_path?
113
+ @receiver.start_with?("#{SELF_HEAD}.")
114
+ end
115
+
116
+ # The key an origin and a report spell this row as.
117
+ def key
118
+ return "#{@receiver}.#{@method}" if receiver_path? || self_path?
119
+ return "#{@receiver}()##{@method}" if @on_result
120
+
121
+ "#{@receiver}#{@singleton ? '.' : '#'}#{@method}"
122
+ end
123
+
124
+ def to_h
125
+ {
126
+ "receiver" => @receiver, "method" => @method.to_s, "singleton" => @singleton,
127
+ "labels" => @labels, "narrow" => @narrow, "discharge" => @discharge, "within" => @within,
128
+ "on_result" => @on_result, "taint" => @taint
129
+ }
130
+ end
131
+
132
+ def ==(other)
133
+ other.is_a?(EffectAttribution) && to_h == other.to_h
134
+ end
135
+ alias eql? ==
136
+
137
+ def hash
138
+ to_h.hash
139
+ end
140
+
141
+ private
142
+
143
+ def validate_receiver!(receiver)
144
+ value = receiver.to_s
145
+ unless CLASS_NAME.match?(value) || RECEIVER_PATH.match?(value) || SELF_PATH.match?(value)
146
+ raise ArgumentError,
147
+ "effect attribution receiver must be a class name, a receiver path or a self path, " \
148
+ "got #{receiver.inspect}"
149
+ end
150
+
151
+ value.dup.freeze
152
+ end
153
+
154
+ # A self path with no `within:` would colour a receiver-less `session` in any project class at all.
155
+ def validate_within!(within)
156
+ if within.nil?
157
+ raise ArgumentError, "effect attribution #{key} is a self path and must name a `within:` class" if self_path?
158
+
159
+ return nil
160
+ end
161
+
162
+ value = within.to_s
163
+ raise ArgumentError, "effect attribution `within:` must be a class name, got #{within.inspect}" unless
164
+ CLASS_NAME.match?(value)
165
+
166
+ value.dup.freeze
167
+ end
168
+
169
+ def normalize_labels(labels)
170
+ list = Array(labels).map { |label| label.to_s.dup.freeze }
171
+ raise ArgumentError, "effect attribution for #{@receiver} must declare at least one label" if list.empty?
172
+
173
+ list.uniq.sort.freeze
174
+ end
175
+
176
+ # `on_result:` shifts the match one link outwards, which only a class-name row can do: a receiver
177
+ # path already names the object, and a self path already names the frame.
178
+ def validate_on_result!
179
+ return unless @on_result
180
+ return unless receiver_path? || self_path?
181
+
182
+ raise ArgumentError, "effect attribution #{key} may not combine `on_result:` with a path receiver"
183
+ end
184
+
185
+ # A row may state a bound AND say the bound is not the whole story. `render` is the case that needs
186
+ # it: what the controller does IS `mutate.self` + `rails.response.write`, and what the TEMPLATE does
187
+ # is unknown until views become effect units (ADR-103 WD11). Reporting only the first would be a
188
+ # summary that reads exhaustive and is not.
189
+ def validate_taint!(taint)
190
+ return nil if taint.nil?
191
+
192
+ value = taint.to_s
193
+ return value.dup.freeze if TAINT_CAUSES.include?(value)
194
+
195
+ raise ArgumentError,
196
+ "effect attribution #{key} may only taint with one of #{TAINT_CAUSES.inspect}, " \
197
+ "got #{taint.inspect}"
198
+ end
199
+
200
+ def validate_why!(why)
201
+ value = why.to_s
202
+ raise ArgumentError, "effect attribution #{key} needs a `why:` justification" if value.empty?
203
+
204
+ value.dup.freeze
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One entry of a plugin's `effect_edges:` — a call graph edge the *syntax* does not contain but the
6
+ # framework does (ADR-103 WD10; design note § 11.2 "Framework edges").
7
+ #
8
+ # `save` runs the class body's `before_save :normalize` and its validators; `Job.perform_now` runs
9
+ # `Job#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. None of those is a call the
10
+ # analyzer can see, and all three are ordinary synchronous in-process control flow — so a plugin that
11
+ # models the framework contributes **edges**, not only labels.
12
+ #
13
+ # What a plugin must NOT contribute is the deferred half: `perform_later` never edges to `perform`,
14
+ # because the body runs in another process on another stack and the caller's code does not contain it
15
+ # (ADR-103 WD4, "attribution follows the code, not the clock"). The enum below has no spelling for it,
16
+ # which is the enforcement.
17
+ #
18
+ # ## Why a fixed enum rather than a callback
19
+ #
20
+ # A plugin supplies **parameters**; the engine owns the strategy. A block would have to run inside the
21
+ # per-file effect scan — the one place ADR-103 WD13 forbids anything that resolves, walks or types —
22
+ # and would not survive the fork-pool / Ractor boundary the collection window crosses. The enum keeps
23
+ # the plugin's contribution declarative, Marshal-clean and reviewable, and keeps every walk on the
24
+ # engine's side of the contract.
25
+ #
26
+ # ## The strategies
27
+ #
28
+ # - `:activerecord_callbacks` — `receiver:` is the ActiveRecord base class. On every project class
29
+ # whose ancestry reaches it, the engine reads the class body's callback and validation macros
30
+ # (`before_save :sym`, `validate :sym`, `after_commit :sym`, …) and synthesises the persistence
31
+ # selectors (`save`, `create!`, `destroy`, `valid?`, …) as effect units edged to those methods.
32
+ # `validates … uniqueness: true` additionally contributes an `io.db.read` origin, because the
33
+ # uniqueness check IS a query.
34
+ # - `:perform_now` — `receiver:` is the job base class. `Job.perform_now(…)` on a project subclass
35
+ # reaches `Job#perform`.
36
+ # - `:mailer_body` — `receiver:` is the mailer base class. `UserMailer.welcome(u)` on a project
37
+ # subclass reaches `UserMailer#welcome`, the class-method-to-instance mapping ActionMailer performs
38
+ # at run time.
39
+ #
40
+ # `method:` and `singleton:` are carried for a future strategy that keys on one selector; the three
41
+ # above read only `receiver:`.
42
+ class EffectEdge
43
+ # Every strategy the engine implements. A `target:` outside this set is a manifest error, so a
44
+ # plugin cannot silently declare an edge nothing honours.
45
+ TARGETS = %i[activerecord_callbacks perform_now mailer_body].freeze
46
+
47
+ attr_reader :receiver, :method, :singleton, :target, :why
48
+
49
+ def initialize(receiver:, target:, why:, method: nil, singleton: false)
50
+ @receiver = validate_receiver!(receiver)
51
+ @target = validate_target!(target)
52
+ @method = method&.to_sym
53
+ @singleton = singleton ? true : false
54
+ @why = validate_why!(why)
55
+ freeze
56
+ end
57
+
58
+ def to_h
59
+ {
60
+ "receiver" => @receiver, "method" => @method&.to_s, "singleton" => @singleton,
61
+ "target" => @target.to_s
62
+ }
63
+ end
64
+
65
+ def ==(other)
66
+ other.is_a?(EffectEdge) && to_h == other.to_h
67
+ end
68
+ alias eql? ==
69
+
70
+ def hash
71
+ to_h.hash
72
+ end
73
+
74
+ private
75
+
76
+ def validate_receiver!(receiver)
77
+ value = receiver.to_s
78
+ unless EffectAttribution::CLASS_NAME.match?(value)
79
+ raise ArgumentError, "effect edge receiver must be a class name, got #{receiver.inspect}"
80
+ end
81
+
82
+ value.dup.freeze
83
+ end
84
+
85
+ def validate_target!(target)
86
+ value = target.to_sym
87
+ return value if TARGETS.include?(value)
88
+
89
+ raise ArgumentError,
90
+ "effect edge target must be one of #{TARGETS.inspect}, got #{target.inspect}"
91
+ end
92
+
93
+ def validate_why!(why)
94
+ value = why.to_s
95
+ raise ArgumentError, "effect edge #{@receiver} -> #{@target} needs a `why:` justification" if value.empty?
96
+
97
+ value.dup.freeze
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # One entry of a plugin's `effect_entry_points:` — a **named** set of globs that
6
+ # `effects.snapshot.reach:` may adopt by name (ADR-103 WD14).
7
+ #
8
+ # `reach:` is "whose transitive footprint does the snapshot record", and the honest answer for a Rails
9
+ # app — controller actions, `perform`, mailer methods, channel methods — is a fact about the framework
10
+ # rather than about the project. So the framework's plugin names it and the project adopts it:
11
+ #
12
+ # effects:
13
+ # snapshot:
14
+ # reach: [rails]
15
+ #
16
+ # A preset is a **glob** set, matched against the project-relative file a method is defined in, with
17
+ # the `rigor unused --entry-point` semantics {Rigor::Effects::EntryPoints} already implements. Globs
18
+ # rather than a class-ancestry filter, deliberately: `reach:` is resolved from the snapshot's own
19
+ # method table, which carries a defining path per key and no ancestry — and a Rails app's layout IS
20
+ # the ancestry, which is why the convention exists at all.
21
+ class EffectEntryPoints
22
+ attr_reader :name, :globs, :why
23
+
24
+ # @param name [String, Symbol] the token `reach:` adopts. Must satisfy
25
+ # {Rigor::Effects::EntryPoints::NAME_PATTERN}, checked at registration.
26
+ # @param globs [Array<String>] project-relative path globs
27
+ # @param why [String] what the preset stands for, for the plugin's README and `rigor effects`' help
28
+ def initialize(name:, globs:, why: "")
29
+ @name = name.to_s.dup.freeze
30
+ @globs = Array(globs).map { |glob| glob.to_s.dup.freeze }.uniq.sort.freeze
31
+ raise ArgumentError, "effect entry-point preset #{@name.inspect} declares no globs" if @globs.empty?
32
+
33
+ @why = why.to_s.dup.freeze
34
+ freeze
35
+ end
36
+
37
+ def to_h
38
+ { "name" => @name, "globs" => @globs }
39
+ end
40
+
41
+ def ==(other)
42
+ other.is_a?(EffectEntryPoints) && to_h == other.to_h
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ to_h.hash
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Which plugin ids the engine itself ships (ADR-103 WD6).
6
+ #
7
+ # WD6's trust ladder grants two things to a **first-party bundled** plugin and to nothing else: it may
8
+ # open the effect-label root of the framework it models rather than one named after itself
9
+ # (`rails.*`, not `activerecord.*`), and its `effect_attributions:` may carry `discharge: true`. Both
10
+ # rest on the same fact — a bundled plugin's framework knowledge is versioned with the engine, reviewed
11
+ # in this repository, and gated by `make check-plugins`, which is exactly the standing a gem's shipped
12
+ # RBS has.
13
+ #
14
+ # The answer is **derived, never listed**: a plugin is first-party when the engine bundles the gem that
15
+ # would register it, which is the same question {Loader.bundled_plugin_path} already answers when it
16
+ # decides whether to require a plugin by its engine-anchored path or by gem name. Every bundled plugin
17
+ # id equals its gem name minus the `rigor-` prefix (pinned by spec), so the id is enough to ask. A list
18
+ # would be a second source of truth to keep in sync with `plugins/`, and the first drift would silently
19
+ # demote a plugin's rows to the tainted lane.
20
+ #
21
+ # This is a trust *ladder*, not a sandbox. ADR-2 settles that plugins are trusted gems the user
22
+ # selected and chooses documentation over forced isolation; a plugin determined to spoof a bundled id
23
+ # is out of scope there and stays out of scope here. What the predicate buys is that an ordinary
24
+ # third-party plugin cannot accidentally claim authority it was never granted.
25
+ module FirstParty
26
+ # The prefix a bundled plugin's gem name carries over its manifest id.
27
+ GEM_PREFIX = "rigor-"
28
+
29
+ @memo = {}
30
+
31
+ class << self
32
+ # Whether `id` names a plugin the engine bundles.
33
+ #
34
+ # Memoised: the answer is a `File.file?` on a path fixed for the process, and the effect surfaces
35
+ # ask it once per plugin per run — but a plugin's own specs ask it far more often than that.
36
+ def bundled?(id)
37
+ key = id.to_s
38
+ return false if key.empty?
39
+
40
+ return @memo[key] if @memo.key?(key)
41
+
42
+ # Required here rather than at the top of the file: {Loader} pulls in the plugin {Registry},
43
+ # which pulls in {Manifest}, which asks this module the question — a `require` cycle that would
44
+ # leave `Loader` undefined half-way through load. The require is idempotent and this method is
45
+ # memoised, so it costs one `$LOADED_FEATURES` probe per process.
46
+ require_relative "loader"
47
+ @memo[key] = !Loader.bundled_plugin_path("#{GEM_PREFIX}#{key}").nil?
48
+ end
49
+
50
+ # Drops the memo. For specs that stub the engine root only.
51
+ def reset!
52
+ @memo = {}
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -43,8 +43,11 @@ module Rigor
43
43
  @target_bundle_root
44
44
  end
45
45
 
46
+ # Frozen so the value stays `Ractor.shareable?`: a Ractor-pool worker READS this (its Environment
47
+ # build resolves the same root and skips the write — #414), and reading an unshareable value out of
48
+ # a module ivar from a non-main Ractor is the same `Ractor::IsolationError` as writing one.
46
49
  def target_bundle_root=(root)
47
- @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
50
+ @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s).freeze
48
51
  end
49
52
 
50
53
  # Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
@@ -3,6 +3,10 @@
3
3
  require_relative "../inference/hkt_registry"
4
4
  require_relative "protocol_contract"
5
5
  require_relative "additional_initializer"
6
+ require_relative "effect_attribution"
7
+ require_relative "effect_edge"
8
+ require_relative "effect_entry_points"
9
+ require_relative "first_party"
6
10
 
7
11
  module Rigor
8
12
  module Plugin
@@ -33,11 +37,16 @@ module Rigor
33
37
  end
34
38
  end
35
39
 
40
+ # ADR-103 WD2 — what a plugin's `effect_root:` may be spelled as: one label segment, the shape
41
+ # {Rigor::Effects::Label} accepts as a root.
42
+ VALID_EFFECT_ROOT = /\A[a-z][a-z0-9_]*\z/
43
+
36
44
  attr_reader :id, :version, :description, :config_schema, :config_defaults, :produces, :consumes,
37
45
  :owns_receivers, :open_receivers, :type_node_resolvers, :block_as_methods,
38
46
  :heredoc_templates, :nested_class_templates, :trait_registries,
39
47
  :hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
40
- :source_rbs_synthesizer, :additional_initializers
48
+ :source_rbs_synthesizer, :additional_initializers,
49
+ :effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points
41
50
 
42
51
  def initialize( # rubocop:disable Metrics/ParameterLists
43
52
  id:, version:,
@@ -46,7 +55,9 @@ module Rigor
46
55
  block_as_methods: [], heredoc_templates: [], nested_class_templates: [],
47
56
  trait_registries: [],
48
57
  hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
49
- source_rbs_synthesizer: nil, additional_initializers: []
58
+ source_rbs_synthesizer: nil, additional_initializers: [],
59
+ effect_root: nil, effect_labels: [], effect_attributions: [], effect_edges: [],
60
+ effect_entry_points: []
50
61
  )
51
62
  validate_id!(id)
52
63
  validate_version!(version)
@@ -65,6 +76,11 @@ module Rigor
65
76
  validate_protocol_contracts!(protocol_contracts)
66
77
  validate_source_rbs_synthesizer!(source_rbs_synthesizer)
67
78
  validate_additional_initializers!(additional_initializers)
79
+ validate_effect_root!(effect_root)
80
+ validate_effect_labels!(effect_labels)
81
+ validate_effect_attributions!(effect_attributions)
82
+ validate_effect_edges!(effect_edges)
83
+ validate_effect_entry_points!(effect_entry_points)
68
84
 
69
85
  assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
70
86
  open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
@@ -72,6 +88,7 @@ module Rigor
72
88
  source_rbs_synthesizer)
73
89
  assign_nested_class_templates(nested_class_templates)
74
90
  assign_additional_initializers(additional_initializers)
91
+ assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges, effect_entry_points)
75
92
  freeze
76
93
  end
77
94
 
@@ -115,6 +132,17 @@ module Rigor
115
132
  @additional_initializers = additional_initializers.dup.freeze
116
133
  end
117
134
  private :assign_additional_initializers
135
+
136
+ # ADR-103 WD2 / WD6 / WD10 / WD14 — the effect contract's five fields, assigned together and outside
137
+ # `assign_fields` (which already carries the maximum positional arity).
138
+ def assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges, effect_entry_points)
139
+ @effect_root = effect_root.nil? ? nil : effect_root.to_s.dup.freeze
140
+ @effect_labels = effect_labels.map { |label| label.to_s.dup.freeze }.uniq.sort.freeze
141
+ @effect_attributions = effect_attributions.dup.freeze
142
+ @effect_edges = effect_edges.dup.freeze
143
+ @effect_entry_points = effect_entry_points.dup.freeze
144
+ end
145
+ private :assign_effect_fields
118
146
  # rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
119
147
 
120
148
  public
@@ -161,10 +189,38 @@ module Rigor
161
189
  "signature_paths" => signature_paths,
162
190
  "protocol_contracts" => protocol_contracts.map(&:to_h),
163
191
  "source_rbs_synthesizer" => source_rbs_synthesizer&.class&.name,
164
- "additional_initializers" => additional_initializers.map(&:to_h)
192
+ "additional_initializers" => additional_initializers.map(&:to_h),
193
+ "effect_root" => effect_root,
194
+ "effect_labels" => effect_labels,
195
+ "effect_attributions" => effect_attributions.map(&:to_h),
196
+ "effect_edges" => effect_edges.map(&:to_h),
197
+ "effect_entry_points" => effect_entry_points.map(&:to_h)
165
198
  }
166
199
  end
167
200
 
201
+ # ADR-103 WD2 — the identity opening a root when this plugin's labels join the run's vocabulary: the
202
+ # framework root it models for a first-party bundled plugin that declares one, and the plugin id
203
+ # otherwise. A third-party plugin's `effect_root:` is ignored here (and warned about by the registry),
204
+ # so it opens only the root named after itself.
205
+ def effect_owner
206
+ return id if effect_root.nil?
207
+ return id unless FirstParty.bundled?(id)
208
+
209
+ effect_root
210
+ end
211
+
212
+ # Whether this plugin's `effect_attributions:` may carry `discharge: true` (ADR-103 WD6).
213
+ def effect_discharge_allowed?
214
+ FirstParty.bundled?(id)
215
+ end
216
+
217
+ # Whether the plugin contributes anything to the effect surfaces at all. The compiled per-run tables
218
+ # skip a plugin that answers false, so a project whose plugins predate this contract pays nothing.
219
+ def effects?
220
+ !effect_labels.empty? || !effect_attributions.empty? || !effect_edges.empty? ||
221
+ !effect_entry_points.empty?
222
+ end
223
+
168
224
  def ==(other)
169
225
  other.is_a?(Manifest) && to_h == other.to_h
170
226
  end
@@ -414,6 +470,50 @@ module Rigor
414
470
  "got #{synthesizer.inspect}"
415
471
  end
416
472
 
473
+ # ADR-103 WD2 — `effect_root:` names the effect-label root this plugin opens when it is one the
474
+ # engine bundles: rigor-activejob models ActiveJob and therefore opens `rails.*`, not `activejob.*`.
475
+ # Declaring one is only ever a *request*; {#effect_owner} grants it to a first-party plugin and
476
+ # ignores it otherwise, so the field is safe to accept from anyone.
477
+ def validate_effect_root!(root)
478
+ return if root.nil?
479
+ return if root.is_a?(String) && root.match?(VALID_EFFECT_ROOT)
480
+
481
+ raise ArgumentError,
482
+ "plugin manifest effect_root must match #{VALID_EFFECT_ROOT.inspect}, got #{root.inspect}"
483
+ end
484
+
485
+ # ADR-103 WD2 — `effect_labels:` lists the vocabulary this plugin registers. The engine folds them
486
+ # into the run's {Rigor::Effects::Registry} through `#with(labels:, owner: manifest.effect_owner)`,
487
+ # which is where root ownership is enforced; a label outside the plugin's root is a load-time plugin
488
+ # error, reported like any other. Only the spelling is checked here.
489
+ def validate_effect_labels!(labels)
490
+ validate_array_of!("effect_labels", labels, "non-empty String") { |l| l.is_a?(String) && !l.empty? }
491
+ end
492
+
493
+ # ADR-103 WD6 / WD10 — `effect_attributions:` colours calls into the framework this plugin models.
494
+ # Each entry MUST be a {Rigor::Plugin::EffectAttribution}.
495
+ def validate_effect_attributions!(entries)
496
+ validate_array_of!("effect_attributions", entries, "Rigor::Plugin::EffectAttribution instances") do |e|
497
+ e.is_a?(EffectAttribution)
498
+ end
499
+ end
500
+
501
+ # ADR-103 WD10 — `effect_edges:` declares the framework edges the syntax lacks. Each entry MUST be a
502
+ # {Rigor::Plugin::EffectEdge}, whose `target:` is one of a fixed engine-side strategy enum.
503
+ def validate_effect_edges!(entries)
504
+ validate_array_of!("effect_edges", entries, "Rigor::Plugin::EffectEdge instances") do |e|
505
+ e.is_a?(EffectEdge)
506
+ end
507
+ end
508
+
509
+ # ADR-103 WD14 — `effect_entry_points:` names the `effects.snapshot.reach:` presets this plugin
510
+ # supplies. Each entry MUST be a {Rigor::Plugin::EffectEntryPoints}.
511
+ def validate_effect_entry_points!(entries)
512
+ validate_array_of!("effect_entry_points", entries, "Rigor::Plugin::EffectEntryPoints instances") do |e|
513
+ e.is_a?(EffectEntryPoints)
514
+ end
515
+ end
516
+
417
517
  def coerce_consumes(consumes)
418
518
  unless consumes.is_a?(Array)
419
519
  raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
@@ -244,6 +244,30 @@ module Rigor
244
244
  # ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
245
245
  attr_reader :node_rule_walk
246
246
 
247
+ # ADR-103 WD2 / WD6 / WD10 / WD14 (#387) — every loaded plugin's effect contribution, in
248
+ # registration order, as {Contribution} rows.
249
+ #
250
+ # **Lazy, unlike the other aggregates.** A plugin MAY compute its `effect_attributions:` from project
251
+ # facts — rigor-activejob reads `config.active_job.queue_adapter` to decide whether an enqueue is a
252
+ # database write or a Redis one — and that is an I/O-boundary read a `rigor check` with no `effects:`
253
+ # block must never pay. Nothing asks for this until {Rigor::Effects::PluginFacts.build} does, and
254
+ # nothing calls that unless collection is on. Memoised into the frozen registry the same way
255
+ # `contracts_for_path` memoises, and for the same reason: the answer is fixed for the run.
256
+ def effect_contributions
257
+ @effect_memo[:contributions] ||= compile_effect_contributions
258
+ end
259
+
260
+ # One plugin's effect contribution, flattened off whichever surface answered it (the manifest, or the
261
+ # plugin's own override). Carries the two authority answers with it — `owner`, the label root this
262
+ # plugin may open, and `discharge_allowed`, whether its attributions may discharge — so nothing
263
+ # downstream has to re-derive who is first-party.
264
+ Contribution = Data.define(:id, :owner, :requested_root, :discharge_allowed, :labels, :attributions,
265
+ :edges, :entry_points) do
266
+ def empty?
267
+ labels.empty? && attributions.empty? && edges.empty? && entry_points.empty?
268
+ end
269
+ end
270
+
247
271
  # ADR-15 Phase 3 — build a fresh Registry from the supplied blueprint set by replaying
248
272
  # {Blueprint#materialize} per entry against `services`. The returned registry carries NEW plugin
249
273
  # instances (mutable per-Ractor accumulators included) and the same blueprint set, so a worker can hand
@@ -379,6 +403,30 @@ module Rigor
379
403
  @open_receivers_set = @open_receivers.to_set.freeze
380
404
  @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
381
405
  @contracts_by_path = {}
406
+ @effect_memo = {}
407
+ end
408
+
409
+ # Fail-soft per plugin: a plugin whose `effect_attributions:` override raises (a missing
410
+ # `config/application.rb`, an unreadable path) contributes nothing rather than taking the run down.
411
+ # An effect contribution is an enrichment; ADR-2 isolates plugin failures at the analyzer boundary
412
+ # and this is that boundary.
413
+ def compile_effect_contributions
414
+ @plugins.filter_map { |plugin| effect_contribution_for(plugin) }.freeze
415
+ end
416
+
417
+ def effect_contribution_for(plugin)
418
+ manifest = safe_manifest(plugin)
419
+ return nil if manifest.nil?
420
+
421
+ contribution = Contribution.new(
422
+ id: manifest.id, owner: manifest.effect_owner, requested_root: manifest.effect_root,
423
+ discharge_allowed: manifest.effect_discharge_allowed?,
424
+ labels: plugin.effect_labels, attributions: plugin.effect_attributions,
425
+ edges: plugin.effect_edges, entry_points: plugin.effect_entry_points
426
+ )
427
+ contribution.empty? ? nil : contribution
428
+ rescue StandardError
429
+ nil
382
430
  end
383
431
 
384
432
  def path_matches_glob?(glob, path)
data/lib/rigor/plugin.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "plugin/load_error"
15
15
  require_relative "plugin/box"
16
16
  require_relative "plugin/isolation"
17
17
  require_relative "plugin/inflector"
18
+ require_relative "plugin/first_party"
18
19
 
19
20
  module Rigor
20
21
  module Plugin