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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activerecord < Rigor::Plugin::Base
8
+ # rigor-activerecord's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
9
+ #
10
+ # ## The two channels, and which row goes where
11
+ #
12
+ # The plugin already ships `sig/active_record/relation.rbs`, so **every `ActiveRecord::Relation`
13
+ # method it declares carries its own `%a{…}` annotation there** — the tier-1 channel, read as an
14
+ # accepted signature and therefore discharging (ADR-103 WD6). That file is also where the builder /
15
+ # materializer split lives, because it is the file that already draws it.
16
+ #
17
+ # What is left over lands here, and each item is left over for a reason RBS cannot fix:
18
+ #
19
+ # - **`ActiveRecord::Base`'s own surface.** The plugin ships no `ActiveRecord::Base` signature and
20
+ # should not: an app's models are typed from `db/schema.rb`, per project, and a generic base-class
21
+ # RBS would fight whatever the project's own `rbs collection` supplies.
22
+ # - **The `Enumerable` delegations on a Relation.** `map`, `filter_map`, `each_with_object` and the
23
+ # rest materialise by delegating to `each`, and declaring them in the bundled RBS would change how
24
+ # they *type* (`Enumerable#map`'s block-return element type is the whole point of the include).
25
+ # - **The connection adapter and the migration DSL**, neither of which the plugin types at all.
26
+ #
27
+ # ## Why the reads and writes are worth spelling out at all
28
+ #
29
+ # An `ActiveRecord::Base` row reaches `User.find` through the project's own
30
+ # `User < ApplicationRecord < ActiveRecord::Base` lines, so one row covers every model in the app.
31
+ # That is the whole economy of the framework layer: thirty-odd rows here colour the several thousand
32
+ # database touches a Rails app performs, and none of them needed a line of application code.
33
+ module Effects
34
+ BASE = "ActiveRecord::Base"
35
+ RELATION = "ActiveRecord::Relation"
36
+ ADAPTER = "ActiveRecord::ConnectionAdapters::AbstractAdapter"
37
+ MIGRATION = "ActiveRecord::Migration"
38
+
39
+ READ = ["io.db.read"].freeze
40
+ WRITE = ["io.db.write"].freeze
41
+ TRANSACTION = ["io.db.transaction"].freeze
42
+ SCHEMA_WRITE = ["io.db.write", "rails.schema.write"].freeze
43
+
44
+ # Class-side finders. Every one issues a `SELECT` the moment it is called — that is what separates
45
+ # them from `where`, which returns a relation and issues nothing.
46
+ SINGLETON_READS = %w[
47
+ find find_by find_by! first first! last last! take take! sole find_sole_by
48
+ second third fourth fifth forty_two second_to_last third_to_last
49
+ exists? any? none? one? many? empty? count sum average minimum maximum calculate
50
+ pluck pick ids find_each find_in_batches in_batches find_by_sql count_by_sql
51
+ find_or_initialize_by
52
+ ].freeze
53
+
54
+ # Class-side writers.
55
+ SINGLETON_WRITES = %w[
56
+ create create! insert insert! insert_all insert_all! upsert upsert_all
57
+ update update! update_all delete delete_all delete_by destroy destroy_all destroy_by
58
+ find_or_create_by find_or_create_by! create_or_find_by create_or_find_by! touch_all
59
+ ].freeze
60
+
61
+ # Instance-side readers. `reload` re-issues the `SELECT` and replaces the record's attributes,
62
+ # which is a receiver mutation as well as a read.
63
+ INSTANCE_READS = %w[valid? invalid? reload].freeze
64
+
65
+ # Instance-side writers. Each is a statement issued now; `save`'s callbacks and validators are the
66
+ # `effect_edges:` half, and arrive as edges rather than labels.
67
+ INSTANCE_WRITES = %w[
68
+ save save! update update! update_attribute update_attributes update_attributes!
69
+ update_column update_columns destroy destroy! delete touch increment! decrement!
70
+ toggle! becomes! insert
71
+ ].freeze
72
+
73
+ # The `Enumerable` surface a Relation inherits. Every one of these runs the query, because every
74
+ # one of them calls `each`.
75
+ RELATION_MATERIALIZERS = %w[
76
+ map flat_map filter_map collect collect_concat select filter reject find detect find_all
77
+ each_with_object each_with_index each_entry each_slice each_cons reduce inject
78
+ group_by partition sort sort_by min min_by max max_by minmax tally zip
79
+ take_while drop_while chunk_while slice_when lazy to_set to_h
80
+ all? any? none? one? include? member? first count sum
81
+ ].freeze
82
+
83
+ # Raw SQL, narrowed by the statement's own leading verb ({Rigor::Effects::Narrowing} `sql_verb`).
84
+ ADAPTER_SQL = %w[execute exec_query exec_insert exec_update exec_delete select_all select_one
85
+ select_value select_values select_rows query query_value query_values].freeze
86
+
87
+ # The migration DSL. `rails.schema.write` is the meaning a reviewer names ("this changes the
88
+ # schema"); `io.db.write` is the transport that makes the summary honest.
89
+ MIGRATION_DSL = %w[
90
+ create_table drop_table rename_table change_table create_join_table drop_join_table
91
+ add_column remove_column rename_column change_column change_column_null
92
+ change_column_default change_column_comment
93
+ add_index remove_index rename_index add_reference remove_reference
94
+ add_foreign_key remove_foreign_key add_check_constraint remove_check_constraint
95
+ add_timestamps remove_timestamps enable_extension disable_extension
96
+ execute add_belongs_to remove_belongs_to
97
+ ].freeze
98
+
99
+ ADAPTER_WHY = "raw SQL; the statement's own leading verb narrows the direction, so a literal " \
100
+ "`execute(\"UPDATE …\")` reads as a write and a computed string keeps the honest " \
101
+ "`io.db`."
102
+
103
+ # Ambient AR calls that are neither a read nor a write of rows.
104
+ TRANSACTIONAL = %w[transaction with_lock lock!].freeze
105
+
106
+ module_function
107
+
108
+ def attributions
109
+ singleton_rows + instance_rows + relation_rows + adapter_rows + migration_rows
110
+ end
111
+
112
+ def singleton_rows
113
+ rows(BASE, SINGLETON_READS, READ, singleton: true,
114
+ why: "issues the SELECT at the call — this is the materializing " \
115
+ "half of the builder/materializer split") +
116
+ rows(BASE, SINGLETON_WRITES, WRITE, singleton: true,
117
+ why: "issues an INSERT / UPDATE / DELETE at the call") +
118
+ rows(BASE, TRANSACTIONAL, TRANSACTION, singleton: true,
119
+ why: "opens a transaction; the block's own origins join " \
120
+ "by containment, so the row states only the BEGIN")
121
+ end
122
+
123
+ def instance_rows
124
+ rows(BASE, INSTANCE_READS, READ,
125
+ why: "re-reads the row (`reload`) or runs the validators, whose uniqueness checks query") +
126
+ rows(BASE, INSTANCE_WRITES, WRITE,
127
+ why: "persists the record — the write a `db: none` envelope is written to catch") +
128
+ rows(BASE, TRANSACTIONAL, TRANSACTION,
129
+ why: "opens a transaction / takes a row lock around the block")
130
+ end
131
+
132
+ def relation_rows
133
+ rows(RELATION, RELATION_MATERIALIZERS, READ,
134
+ why: "an Enumerable delegation on a Relation: it calls `each`, which runs the query. Not " \
135
+ "declared in the bundled RBS because declaring it would change how it types")
136
+ end
137
+
138
+ # Two rows per selector, because raw SQL is written two ways and only one of them names a type.
139
+ # `adapter.execute(sql)` on a receiver the typer managed to name is the first; the second matches
140
+ # `ActiveRecord::Base.connection.execute(sql)` and `User.connection.exec_query(sql)`, where the
141
+ # connection object has no declared type but the class that handed it over is written in the source.
142
+ def adapter_rows
143
+ ADAPTER_SQL.flat_map do |selector|
144
+ [
145
+ EffectAttribution.new(
146
+ receiver: ADAPTER, method: selector, labels: ["io.db"], narrow: "sql_verb", discharge: true,
147
+ why: ADAPTER_WHY
148
+ ),
149
+ EffectAttribution.new(
150
+ receiver: BASE, method: selector, labels: ["io.db"], narrow: "sql_verb", on_result: true,
151
+ discharge: true,
152
+ why: "#{ADAPTER_WHY} Matched on the result of `Model.connection`, which is how raw SQL is " \
153
+ "actually spelled in a Rails app."
154
+ )
155
+ ]
156
+ end
157
+ end
158
+
159
+ def migration_rows
160
+ rows(MIGRATION, MIGRATION_DSL, SCHEMA_WRITE,
161
+ why: "the migration DSL issues DDL: `io.db.write` is the transport, `rails.schema.write` " \
162
+ "the meaning a `db/migrate/**` envelope names")
163
+ end
164
+
165
+ def rows(receiver, selectors, labels, why:, singleton: false)
166
+ selectors.map do |selector|
167
+ EffectAttribution.new(receiver: receiver, method: selector, labels: labels,
168
+ singleton: singleton, discharge: true, why: why)
169
+ end
170
+ end
171
+
172
+ # ADR-103 WD10 — the one edge ActiveRecord contributes: `save` runs the class body's callbacks and
173
+ # validators. The engine owns the walk; this names the base class whose descendants it applies to.
174
+ def edges
175
+ [
176
+ EffectEdge.new(
177
+ receiver: BASE, target: :activerecord_callbacks,
178
+ why: "`before_save :normalize` / `validate :check` / `after_commit :notify` are real, " \
179
+ "synchronous, in-process calls that no syntax at the call site contains"
180
+ )
181
+ ]
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -8,6 +8,7 @@ require_relative "activerecord/structure_sql_parser"
8
8
  require_relative "activerecord/model_index"
9
9
  require_relative "activerecord/model_discoverer"
10
10
  require_relative "activerecord/analyzer"
11
+ require_relative "activerecord/effects"
11
12
 
12
13
  module Rigor
13
14
  module Plugin
@@ -72,7 +73,17 @@ module Rigor
72
73
  # ADR-26 — `ActiveRecord::Relation` is an "open" receiver: it delegates an unbounded set of
73
74
  # user-defined scopes / class methods to its model, so `call.undefined-method` must not fire for
74
75
  # it. `CheckRules` reads this manifest field and skips the rule for the class.
75
- open_receivers: ["ActiveRecord::Relation"]
76
+ open_receivers: ["ActiveRecord::Relation"],
77
+ # ADR-103 WD2 / WD10 (#387) — the effect layer. rigor-activerecord models ActiveRecord, which is
78
+ # part of Rails, so it opens the framework's own `rails.*` root rather than one named after the
79
+ # plugin; `effect_root:` is granted only because the engine bundles this plugin
80
+ # ({Rigor::Plugin::FirstParty}). Every row's justification is in {Effects}, and the
81
+ # `ActiveRecord::Relation` half is in the bundled `sig/active_record/relation.rbs` instead —
82
+ # tier 1, because the plugin already ships that signature.
83
+ effect_root: "rails",
84
+ effect_labels: ["rails.schema.write"],
85
+ effect_attributions: Effects.attributions,
86
+ effect_edges: Effects.edges
76
87
  )
77
88
 
78
89
  # The class the bundled `sig/active_record/relation.rbs` describes; `dynamic_return` contributes
@@ -26,6 +26,26 @@
26
26
  # `rbs collection`), that definition conflicts with this one and
27
27
  # the conflict degrades through Rigor's plugin-RBS failure memo
28
28
  # (ADR-25 WD4) — the richer upstream definition simply wins.
29
+ #
30
+ # ## Effect annotations (ADR-103 WD10, issue #387)
31
+ #
32
+ # Every method carries an effect annotation, and the split down
33
+ # the middle of this file is the whole point of the Rails effect
34
+ # layer: a **builder** is `%a{pure}` because it issues nothing —
35
+ # `where` composes an Arel tree and returns a relation — while a
36
+ # **materializer** is `%a{rigor:v1:effect io.db.read}` because it
37
+ # is the call that finally runs the query. A presenter that builds
38
+ # and returns a scope has pure code; the caller that materialises
39
+ # it gets the read. That is the truthful reading of what the code
40
+ # does, not of how Rails developers narrate it.
41
+ #
42
+ # This is the plugin's tier-1 channel: these are *accepted
43
+ # signatures*, so a call site importing one of these bounds is
44
+ # exhaustive rather than tainted (ADR-103 WD6). The methods this
45
+ # file does not declare — `map`, `filter_map` and the rest of the
46
+ # `Enumerable` surface, which materialise by delegating to `each`
47
+ # — are covered by the plugin's `effect_attributions:` instead,
48
+ # because declaring them here would change how they TYPE.
29
49
 
30
50
  module ActiveRecord
31
51
  class Relation[Elem]
@@ -34,149 +54,274 @@ module ActiveRecord
34
54
  # `Enumerable` only *requires* `each` (the includer supplies
35
55
  # it); it is declared here so `relation.each { |m| ... }`
36
56
  # resolves and the block parameter carries the element type.
57
+ %a{rigor:v1:effect io.db.read}
37
58
  def each: () { (Elem) -> void } -> self
38
59
  | () -> Enumerator[Elem, self]
39
60
 
40
61
  # --- query builders (return another relation) ---
62
+ %a{pure}
41
63
  def where: (*untyped) ?{ () -> untyped } -> self
64
+ %a{pure}
42
65
  def rewhere: (*untyped) -> self
66
+ %a{pure}
43
67
  def not: (*untyped) -> self
68
+ %a{pure}
44
69
  def or: (untyped) -> self
70
+ %a{pure}
45
71
  def and: (untyped) -> self
72
+ %a{pure}
46
73
  def order: (*untyped) -> self
74
+ %a{pure}
47
75
  def reorder: (*untyped) -> self
76
+ %a{pure}
48
77
  def in_order_of: (*untyped) -> self
78
+ %a{pure}
49
79
  def limit: (untyped) -> self
80
+ %a{pure}
50
81
  def offset: (untyped) -> self
82
+ %a{pure}
51
83
  def distinct: (?untyped) -> self
84
+ %a{pure}
52
85
  def group: (*untyped) -> self
86
+ %a{pure}
53
87
  def regroup: (*untyped) -> self
88
+ %a{pure}
54
89
  def having: (*untyped) -> self
90
+ %a{pure}
55
91
  def includes: (*untyped) -> self
92
+ %a{pure}
56
93
  def preload: (*untyped) -> self
94
+ %a{pure}
57
95
  def eager_load: (*untyped) -> self
96
+ %a{pure}
58
97
  def joins: (*untyped) -> self
98
+ %a{pure}
59
99
  def left_joins: (*untyped) -> self
100
+ %a{pure}
60
101
  def left_outer_joins: (*untyped) -> self
102
+ %a{pure}
61
103
  def references: (*untyped) -> self
104
+ %a{pure}
62
105
  def select: (*untyped) -> self
106
+ %a{pure}
63
107
  def reselect: (*untyped) -> self
108
+ %a{pure}
64
109
  def unscope: (*untyped) -> self
110
+ %a{pure}
65
111
  def merge: (untyped) -> self
112
+ %a{pure}
66
113
  def except: (*untyped) -> self
114
+ %a{pure}
67
115
  def only: (*untyped) -> self
116
+ %a{pure}
68
117
  def extending: (*untyped) -> self
118
+ %a{pure}
69
119
  def from: (untyped, ?untyped) -> self
120
+ %a{pure}
70
121
  def lock: (?untyped) -> self
122
+ %a{pure}
71
123
  def readonly: (?untyped) -> self
124
+ %a{pure}
72
125
  def strict_loading: (?untyped) -> self
126
+ %a{pure}
73
127
  def create_with: (untyped) -> self
128
+ %a{pure}
74
129
  def none: () -> self
130
+ %a{pure}
75
131
  def all: () -> self
132
+ %a{pure}
76
133
  def unscoped: () -> self
134
+ %a{pure}
77
135
  def default_scoped: () -> self
136
+ %a{pure}
78
137
  def annotate: (*untyped) -> self
138
+ %a{pure}
79
139
  def optimizer_hints: (*untyped) -> self
140
+ %a{pure}
80
141
  def excluding: (*untyped) -> self
142
+ %a{pure}
81
143
  def without: (*untyped) -> self
144
+ %a{pure}
82
145
  def with: (*untyped) -> self
83
146
  def scoping: () { () -> untyped } -> untyped
84
147
 
85
148
  # --- finders (return an element) ---
149
+ %a{rigor:v1:effect io.db.read}
86
150
  def find: (*untyped) -> Elem
151
+ %a{rigor:v1:effect io.db.read}
87
152
  def find_by: (*untyped) -> Elem?
153
+ %a{rigor:v1:effect io.db.read}
88
154
  def find_by!: (*untyped) -> Elem
155
+ %a{rigor:v1:effect io.db.read}
89
156
  def find_sole_by: (*untyped) -> Elem
157
+ %a{rigor:v1:effect io.db.read}
90
158
  def sole: () -> Elem
159
+ %a{rigor:v1:effect io.db.read}
91
160
  def first: () -> Elem?
92
161
  | (Integer limit) -> Array[Elem]
162
+ %a{rigor:v1:effect io.db.read}
93
163
  def first!: () -> Elem
164
+ %a{rigor:v1:effect io.db.read}
94
165
  def last: () -> Elem?
95
166
  | (Integer limit) -> Array[Elem]
167
+ %a{rigor:v1:effect io.db.read}
96
168
  def last!: () -> Elem
169
+ %a{rigor:v1:effect io.db.read}
97
170
  def take: () -> Elem?
98
171
  | (Integer limit) -> Array[Elem]
172
+ %a{rigor:v1:effect io.db.read}
99
173
  def take!: () -> Elem
174
+ %a{rigor:v1:effect io.db.read}
100
175
  def second: () -> Elem?
176
+ %a{rigor:v1:effect io.db.read}
101
177
  def third: () -> Elem?
178
+ %a{rigor:v1:effect io.db.read}
102
179
  def fourth: () -> Elem?
180
+ %a{rigor:v1:effect io.db.read}
103
181
  def fifth: () -> Elem?
182
+ %a{rigor:v1:effect io.db.read}
104
183
  def forty_two: () -> Elem?
184
+ %a{rigor:v1:effect io.db.read}
105
185
  def second_to_last: () -> Elem?
186
+ %a{rigor:v1:effect io.db.read}
106
187
  def third_to_last: () -> Elem?
107
188
 
108
189
  # --- batched enumeration ---
190
+ %a{rigor:v1:effect io.db.read}
109
191
  def find_each: (**untyped) ?{ (Elem) -> void } -> untyped
192
+ %a{rigor:v1:effect io.db.read}
110
193
  def find_in_batches: (**untyped) ?{ (Array[Elem]) -> void } -> untyped
194
+ %a{rigor:v1:effect io.db.read}
111
195
  def in_batches: (**untyped) ?{ (ActiveRecord::Relation[Elem]) -> void } -> untyped
112
196
 
113
197
  # --- scalars / aggregates ---
198
+ %a{rigor:v1:effect io.db.read}
114
199
  def count: (*untyped) ?{ (Elem) -> untyped } -> untyped
200
+ %a{rigor:v1:effect io.db.read}
115
201
  def sum: (*untyped) ?{ (Elem) -> untyped } -> untyped
202
+ %a{rigor:v1:effect io.db.read}
116
203
  def average: (untyped) -> untyped
204
+ %a{rigor:v1:effect io.db.read}
117
205
  def minimum: (untyped) -> untyped
206
+ %a{rigor:v1:effect io.db.read}
118
207
  def maximum: (untyped) -> untyped
208
+ %a{rigor:v1:effect io.db.read}
119
209
  def calculate: (*untyped) -> untyped
210
+ %a{rigor:v1:effect io.db.read}
120
211
  def pluck: (*untyped) -> Array[untyped]
212
+ %a{rigor:v1:effect io.db.read}
121
213
  def pick: (*untyped) -> untyped
214
+ %a{rigor:v1:effect io.db.read}
122
215
  def ids: () -> Array[untyped]
216
+ %a{rigor:v1:effect io.db.read}
123
217
  def size: () -> Integer
218
+ %a{rigor:v1:effect io.db.read}
124
219
  def length: () -> Integer
220
+ %a{rigor:v1:effect io.db.read}
125
221
  def empty?: () -> bool
222
+ %a{rigor:v1:effect io.db.read}
126
223
  def any?: (*untyped) ?{ (Elem) -> boolish } -> bool
224
+ %a{rigor:v1:effect io.db.read}
127
225
  def none?: (*untyped) ?{ (Elem) -> boolish } -> bool
226
+ %a{rigor:v1:effect io.db.read}
128
227
  def one?: (*untyped) ?{ (Elem) -> boolish } -> bool
228
+ %a{rigor:v1:effect io.db.read}
129
229
  def many?: () ?{ (Elem) -> boolish } -> bool
230
+ %a{rigor:v1:effect io.db.read}
130
231
  def exists?: (*untyped) -> bool
232
+ %a{rigor:v1:effect io.db.read}
131
233
  def include?: (untyped) -> bool
234
+ %a{rigor:v1:effect io.db.read}
132
235
  def member?: (untyped) -> bool
133
236
 
134
237
  # --- conversion / state ---
238
+ %a{rigor:v1:effect io.db.read}
135
239
  def to_a: () -> Array[Elem]
240
+ %a{rigor:v1:effect io.db.read}
136
241
  def to_ary: () -> Array[Elem]
242
+ %a{rigor:v1:effect io.db.read}
137
243
  def entries: () -> Array[Elem]
244
+ %a{rigor:v1:effect io.db.read}
138
245
  def load: (?untyped) -> self
246
+ %a{rigor:v1:effect io.db.read}
139
247
  def reload: () -> self
248
+ %a{pure}
140
249
  def reset: () -> self
250
+ %a{pure}
141
251
  def loaded?: () -> bool
252
+ %a{pure}
142
253
  def to_sql: () -> String
254
+ %a{rigor:v1:effect io.db.read}
143
255
  def explain: (*untyped) -> String
256
+ %a{rigor:v1:effect io.db.read}
144
257
  def cache_key: (?untyped) -> String
258
+ %a{rigor:v1:effect io.db.read}
145
259
  def cache_version: (?untyped) -> untyped
260
+ %a{rigor:v1:effect io.db.read}
146
261
  def to_json: (*untyped) -> String
262
+ %a{rigor:v1:effect io.db.read}
147
263
  def as_json: (?untyped) -> untyped
264
+ %a{rigor:v1:effect io.db.read}
148
265
  def blank?: () -> bool
266
+ %a{rigor:v1:effect io.db.read}
149
267
  def present?: () -> bool
268
+ %a{pure}
150
269
  def klass: () -> untyped
270
+ %a{pure}
151
271
  def model: () -> untyped
272
+ %a{pure}
152
273
  def table: () -> untyped
274
+ %a{pure}
153
275
  def arel: () -> untyped
154
276
 
155
277
  # --- whole-relation persistence ---
278
+ %a{rigor:v1:effect io.db.write}
156
279
  def update_all: (untyped) -> Integer
280
+ %a{rigor:v1:effect io.db.write}
157
281
  def delete_all: () -> Integer
282
+ %a{rigor:v1:effect io.db.write}
158
283
  def destroy_all: () -> Array[Elem]
284
+ %a{rigor:v1:effect io.db.write}
159
285
  def update: (*untyped) -> untyped
286
+ %a{rigor:v1:effect io.db.write}
160
287
  def update!: (*untyped) -> untyped
288
+ %a{rigor:v1:effect io.db.write}
161
289
  def delete_by: (*untyped) -> Integer
290
+ %a{rigor:v1:effect io.db.write}
162
291
  def destroy_by: (*untyped) -> Array[Elem]
292
+ %a{rigor:v1:effect io.db.write}
163
293
  def touch_all: (*untyped) -> Integer
294
+ %a{rigor:v1:effect io.db.write}
164
295
  def insert_all: (untyped, **untyped) -> untyped
296
+ %a{rigor:v1:effect io.db.write}
165
297
  def insert_all!: (untyped, **untyped) -> untyped
298
+ %a{rigor:v1:effect io.db.write}
166
299
  def upsert_all: (untyped, **untyped) -> untyped
167
300
 
168
301
  # --- instantiating builders (return an element) ---
302
+ %a{pure}
169
303
  def new: (*untyped) ?{ (Elem) -> void } -> Elem
304
+ %a{pure}
170
305
  def build: (*untyped) ?{ (Elem) -> void } -> Elem
306
+ %a{rigor:v1:effect io.db.write}
171
307
  def create: (*untyped) ?{ (Elem) -> void } -> Elem
308
+ %a{rigor:v1:effect io.db.write}
172
309
  def create!: (*untyped) ?{ (Elem) -> void } -> Elem
310
+ %a{rigor:v1:effect io.db.write}
173
311
  def find_or_create_by: (untyped) ?{ (Elem) -> void } -> Elem
312
+ %a{rigor:v1:effect io.db.write}
174
313
  def find_or_create_by!: (untyped) ?{ (Elem) -> void } -> Elem
314
+ %a{rigor:v1:effect io.db.read}
175
315
  def find_or_initialize_by: (untyped) ?{ (Elem) -> void } -> Elem
316
+ %a{rigor:v1:effect io.db.write}
176
317
  def create_or_find_by: (untyped) ?{ (Elem) -> void } -> Elem
318
+ %a{rigor:v1:effect io.db.write}
177
319
  def create_or_find_by!: (untyped) ?{ (Elem) -> void } -> Elem
320
+ %a{rigor:v1:effect io.db.write}
178
321
  def first_or_create: (?untyped) ?{ (Elem) -> void } -> Elem
322
+ %a{rigor:v1:effect io.db.write}
179
323
  def first_or_create!: (?untyped) ?{ (Elem) -> void } -> Elem
324
+ %a{rigor:v1:effect io.db.read}
180
325
  def first_or_initialize: (?untyped) ?{ (Elem) -> void } -> Elem
181
326
  end
182
327
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activestorage < Rigor::Plugin::Base
8
+ # rigor-activestorage's effect contract (ADR-103 WD10; design note § 11.2; issue #387).
9
+ #
10
+ # ActiveStorage is the design note's argument for framework labels in miniature: the *transport* is
11
+ # whatever `config/storage.yml` names — the local disk, S3, GCS — and is therefore statically
12
+ # unknowable, while the *operation* is fixed. So every row carries bare `io` for the transport and
13
+ # `rails.activestorage.read` / `.write` for the meaning, which is the half a policy can grip.
14
+ #
15
+ # Almost every write is also an `io.db.write`: an attachment is a row in `active_storage_attachments`
16
+ # and a blob is a row in `active_storage_blobs`, so `user.avatar.attach(io)` really does touch the
17
+ # database whether or not the bytes go to S3. A "no database writes on this path" envelope is right
18
+ # to object, and would be wrong not to.
19
+ module Effects
20
+ ATTACHED_ONE = "ActiveStorage::Attached::One"
21
+ ATTACHED_MANY = "ActiveStorage::Attached::Many"
22
+ BLOB = "ActiveStorage::Blob"
23
+ ATTACHMENT = "ActiveStorage::Attachment"
24
+
25
+ READ = ["io", "rails.activestorage.read"].freeze
26
+ WRITE = ["io", "rails.activestorage.write", "io.db.write"].freeze
27
+ # `download` / `open` pull bytes out of the service and touch no row.
28
+ PURE_READ = ["io", "rails.activestorage.read"].freeze
29
+
30
+ ATTACH_TARGETS = [ATTACHED_ONE, ATTACHED_MANY].freeze
31
+
32
+ module_function
33
+
34
+ def attributions
35
+ attach_rows + blob_rows
36
+ end
37
+
38
+ def attach_rows
39
+ ATTACH_TARGETS.flat_map do |receiver|
40
+ [
41
+ row(receiver, :attach, WRITE,
42
+ "uploads the bytes to the configured service AND inserts the attachment + blob rows"),
43
+ row(receiver, :purge, WRITE, "deletes the stored file and the attachment + blob rows"),
44
+ row(receiver, :purge_later, WRITE,
45
+ "enqueues the purge; the attachment row is detached now, so the database write is here"),
46
+ row(receiver, :detach, ["io.db.write"], "removes the attachment row and touches no service"),
47
+ row(receiver, :download, PURE_READ, "pulls the bytes back out of the service"),
48
+ row(receiver, :open, PURE_READ, "streams the bytes into a tempfile")
49
+ ]
50
+ end
51
+ end
52
+
53
+ def blob_rows
54
+ [
55
+ row(BLOB, :download, PURE_READ, "reads the object out of the storage service"),
56
+ row(BLOB, :open, PURE_READ, "streams the object into a tempfile"),
57
+ row(BLOB, :upload, WRITE, "writes the object to the service and records its checksum"),
58
+ row(BLOB, :purge, WRITE, "deletes the object and its row"),
59
+ row(BLOB, :purge_later, WRITE, "enqueues the object's deletion; the row goes now"),
60
+ row(BLOB, :create_and_upload!, WRITE, "inserts the blob row and uploads in one call",
61
+ singleton: true),
62
+ row(BLOB, :url, READ, "signs a URL against the service, which for some services is a request"),
63
+ row(ATTACHMENT, :purge, WRITE, "deletes the stored file and the attachment row"),
64
+ row(ATTACHMENT, :download, PURE_READ, "reads the attached object out of the service")
65
+ ]
66
+ end
67
+
68
+ def row(receiver, selector, labels, why, singleton: false)
69
+ EffectAttribution.new(receiver: receiver, method: selector, labels: labels,
70
+ singleton: singleton, discharge: true,
71
+ why: "#{why}; the transport is bare `io` because `config/storage.yml` " \
72
+ "decides it per environment and no static reading can")
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -5,6 +5,7 @@ require "rigor/plugin"
5
5
  require_relative "activestorage/attachment_discoverer"
6
6
  require_relative "activestorage/attachment_index"
7
7
  require_relative "activestorage/analyzer"
8
+ require_relative "activestorage/effects"
8
9
 
9
10
  module Rigor
10
11
  module Plugin
@@ -41,7 +42,11 @@ module Rigor
41
42
  config_schema: {
42
43
  "model_search_paths" => { kind: :array, default: ["app/models"] }
43
44
  },
44
- consumes: [{ plugin_id: "activerecord", name: :model_index, optional: true }]
45
+ consumes: [{ plugin_id: "activerecord", name: :model_index, optional: true }],
46
+ # ADR-103 WD10 (#387) — see {Effects} for what each row is and why.
47
+ effect_root: "rails",
48
+ effect_labels: %w[rails.activestorage.read rails.activestorage.write],
49
+ effect_attributions: Effects.attributions
45
50
  )
46
51
 
47
52
  # Cached: attachment index. Walks every `.rb` file under `model_search_paths` for `has_*_attached`