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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activejob < Rigor::Plugin::Base
8
+ # rigor-activejob's effect contract (ADR-103 WD4 / WD10; design note § 11.2 "Deferred execution";
9
+ # issue #387).
10
+ #
11
+ # ## Attribution follows the code, not the clock
12
+ #
13
+ # `perform_later` is the effect; `perform` is not. The job body runs in another process on another
14
+ # stack, so the caller's code does not contain it and there is **no edge** from `perform_later` to
15
+ # `perform` — {Rigor::Plugin::EffectEdge::TARGETS} has no spelling for one. `perform_now` is an
16
+ # ordinary edge, because it is an ordinary call.
17
+ #
18
+ # `set(wait: 1.hour)` returns a `ConfiguredJob` and does nothing, exactly like a Relation builder; the
19
+ # `.perform_later` on it is the origin, which is what `on_result:` expresses.
20
+ #
21
+ # ## The transport is a project fact
22
+ #
23
+ # Argument-blind, an enqueue is bare `io` — true and useless for policy. But a Rails app declares its
24
+ # adapter once, in `config.active_job.queue_adapter`, and reading it turns the row into something a
25
+ # reviewer can act on: under Solid Queue the enqueue is an `INSERT` into `solid_queue_jobs`, and a
26
+ # "no database on this path" envelope is right to object to it. Under Sidekiq it is a Redis round
27
+ # trip. Under `:async` it is a thread in this process and no transport at all; under `:inline` Rails
28
+ # runs the job on the caller's stack, which — and only which — makes `perform_later` a real edge.
29
+ #
30
+ # This is the configuration-level twin of the argument-dependent narrowing the catalogue does for
31
+ # `File.open`'s mode: the same discipline, one scope wider. An unread or per-environment adapter keeps
32
+ # the honest `io`.
33
+ module Effects
34
+ BASE = "ActiveJob::Base"
35
+
36
+ # Where a Rails app declares its adapter. Per-environment files are read too, because an app that
37
+ # sets the adapter only in `config/environments/production.rb` is common; agreement across every
38
+ # file that names one is what licenses the narrowing (see {.transport_for}).
39
+ CONFIG_PATHS = ["config/application.rb", "config/environments"].freeze
40
+
41
+ SETTING = /queue_adapter\s*=\s*[:"']([a-z_]+)["']?/
42
+
43
+ # What each adapter's enqueue actually touches. The absent adapters are the point: anything not
44
+ # listed keeps bare `io`, which is the truthful upper bound for a transport nobody named.
45
+ TRANSPORTS = {
46
+ "solid_queue" => ["io.db.write"], "delayed_job" => ["io.db.write"], "delayed" => ["io.db.write"],
47
+ "que" => ["io.db.write"], "good_job" => ["io.db.write"], "queue_classic" => ["io.db.write"],
48
+ "sidekiq" => ["io.net"], "resque" => ["io.net"], "sneakers" => ["io.net"],
49
+ "shoryuken" => ["io.net"], "backburner" => ["io.net"], "sucker_punch" => [],
50
+ "async" => [], "inline" => [], "test" => []
51
+ }.freeze
52
+
53
+ # The meaning half, which is adapter-independent and is what a policy actually names.
54
+ MEANING = ["rails.activejob.enqueue", "job.enqueue"].freeze
55
+
56
+ # `X.perform_later`, `X.perform_all_later`, `X.enqueue` — the class-side enqueues.
57
+ SINGLETON_ENQUEUES = %w[perform_later perform_all_later enqueue enqueue_at].freeze
58
+
59
+ # `job.enqueue`, and the enqueue on whatever `set(…)` returned.
60
+ RESULT_ENQUEUES = %w[perform_later enqueue enqueue_at].freeze
61
+
62
+ module_function
63
+
64
+ # @param adapter [String, nil] the adapter the project declares, or nil when it declares none
65
+ # (or more than one, across environments)
66
+ def attributions(adapter)
67
+ labels = (TRANSPORTS[adapter] || ["io"]) + MEANING
68
+ why = transport_why(adapter)
69
+ SINGLETON_ENQUEUES.map do |selector|
70
+ EffectAttribution.new(receiver: BASE, method: selector, labels: labels, singleton: true,
71
+ discharge: true, why: why)
72
+ end +
73
+ RESULT_ENQUEUES.map do |selector|
74
+ EffectAttribution.new(
75
+ receiver: BASE, method: selector, labels: labels, on_result: true, discharge: true,
76
+ why: "#{why} Matched on the RESULT of a call to the job class, which is the shape " \
77
+ "`WelcomeJob.set(wait: 1.hour).perform_later` takes: `set` is a builder and returns a " \
78
+ "lazy ConfiguredJob, so the enqueue is one link further out."
79
+ )
80
+ end +
81
+ [EffectAttribution.new(receiver: BASE, method: :enqueue, labels: labels, discharge: true,
82
+ why: why)]
83
+ end
84
+
85
+ def transport_why(adapter)
86
+ if adapter.nil?
87
+ return "the queue adapter is unread or differs per environment, so the transport stays the " \
88
+ "honest `io`; `rails.activejob.enqueue` is the meaning a policy names."
89
+ end
90
+
91
+ transport = TRANSPORTS[adapter]
92
+ if transport.nil?
93
+ return "`config.active_job.queue_adapter = :#{adapter}` is not an adapter this plugin has a " \
94
+ "transport for, so the row keeps bare `io`."
95
+ end
96
+ if transport.empty?
97
+ return "`config.active_job.queue_adapter = :#{adapter}` runs the queue inside this process, so " \
98
+ "the enqueue crosses no transport at all."
99
+ end
100
+
101
+ "`config.active_job.queue_adapter = :#{adapter}` makes the enqueue a #{transport.first} — the " \
102
+ "project's own configuration narrowing a transport that is otherwise unknowable."
103
+ end
104
+
105
+ # `perform_now` is always an edge. Under `:inline`, so is `perform_later`: Rails genuinely runs the
106
+ # job on the caller's stack there, and refusing the edge would understate a controller action that
107
+ # sends the whole email inline.
108
+ def edges(adapter)
109
+ list = [
110
+ EffectEdge.new(receiver: BASE, target: :perform_now,
111
+ why: "`Job.perform_now(...)` runs `Job#perform` synchronously, in this process")
112
+ ]
113
+ return list unless adapter == "inline"
114
+
115
+ list << EffectEdge.new(
116
+ receiver: BASE, target: :perform_now, method: :perform_later,
117
+ why: "`config.active_job.queue_adapter = :inline` makes `perform_later` run `perform` on the " \
118
+ "caller's stack; the edge is licensed by the project's own declaration and by nothing else"
119
+ )
120
+ end
121
+
122
+ # `reach: [rails-jobs]` — a job's `perform` is an entry point: nothing in the project calls it, and
123
+ # its footprint is what a reviewer asking "what does this job do" wants.
124
+ def entry_points
125
+ [
126
+ EffectEntryPoints.new(
127
+ name: "rails-jobs", globs: ["app/jobs/**/*.rb"],
128
+ why: "ActiveJob jobs — `perform` is invoked by the queue, never by project code"
129
+ )
130
+ ]
131
+ end
132
+
133
+ # Reads `config.active_job.queue_adapter` out of the project's configuration. Returns the adapter
134
+ # name when every file that names one agrees, and nil when none does or they disagree — a
135
+ # per-environment split has no single transport, and guessing one would be the wrong kind of
136
+ # precision.
137
+ def detect_adapter(io_boundary, root)
138
+ found = config_files(root).filter_map do |path|
139
+ io_boundary.read_file(path).force_encoding("UTF-8")[SETTING, 1]
140
+ rescue StandardError
141
+ nil
142
+ end.uniq
143
+ found.length == 1 ? found.first : nil
144
+ end
145
+
146
+ def config_files(root)
147
+ application = File.join(root, CONFIG_PATHS.first)
148
+ environments = Dir.glob(File.join(root, CONFIG_PATHS.last, "*.rb"))
149
+ ([application] + environments).select { |path| File.file?(path) }
150
+ rescue StandardError
151
+ []
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activejob < Rigor::Plugin::Base
8
+ # ADR-102 WD3 / #369 — which jobs does this project run on a schedule it names them in by STRING?
9
+ #
10
+ # `MyJob.perform_later(...)` writes the job's name as an ordinary constant, so the reachability scan
11
+ # already records that edge and a root would add nothing. Solid Queue's recurring tasks are the
12
+ # opposite case, and they are why this file exists: a recurring job is named only as
13
+ # `class: "SendReminderJob"` in `config/recurring.yml`, so a repository running it every three minutes
14
+ # may contain no `perform_later` for it anywhere. The constant scan sees nothing and `rigor unused`
15
+ # reports a production job as dead code — measured on a real Rails 8 application, where one miss
16
+ # cascaded into a second row because the only caller of a helper class was the job itself.
17
+ #
18
+ # Solid Queue is the default Active Job backend from Rails 8, so this is a mainstream layout rather
19
+ # than a niche one. This is also the ONLY root source this plugin has: #350 declined to publish the
20
+ # discovered job set, because "a file exists under `app/jobs`" is not evidence that anything enqueues
21
+ # it, and an over-claiming root source silently hides real dead code (ADR-102 § Consequences).
22
+ #
23
+ # **Two layouts, one key.** Rails 8 keys `recurring.yml` by environment at the top level
24
+ # (`production:` → task name → entry), unlike `sidekiq-cron`'s flat document. Both depths are read
25
+ # rather than guessed at, and reading both is safe because neither layout yields anything under the
26
+ # other's reading: an environment block's values are entry Hashes whose own `class:` is absent, and a
27
+ # flat entry's values are Strings rather than Hashes. Every environment is read — a job scheduled only
28
+ # in `staging:` is still live code, and picking `production:` would report it dead.
29
+ #
30
+ # **A `command:` entry supplies nothing.** Solid Queue also accepts inline Ruby
31
+ # (`command: "SomeModel.cleanup"`), which names no class the way `class:` does. Parsing a constant out
32
+ # of an arbitrary Ruby snippet would manufacture roots from a string, so `class:` is the only key read.
33
+ #
34
+ # Names are not trusted either: the caller intersects them with the jobs {JobDiscoverer} actually
35
+ # found, so a typo or an out-of-tree class costs coverage rather than manufacturing a root, and the
36
+ # report's `matched no declaration` counter stays meaningful.
37
+ #
38
+ # Fail-soft throughout, because this reads user-authored config Rigor does not own: an absent file, an
39
+ # unreadable one, a YAML syntax error, or a document that is not a Hash contributes nothing rather than
40
+ # raising. Never boots Rails and never loads Solid Queue.
41
+ class RecurringScan
42
+ # The key naming the job class. `command:` is deliberately not read; see the class comment.
43
+ CLASS_KEY = "class"
44
+
45
+ # Errno classes that mean "this path is not readable as a schedule" — swallowed so one bad path does
46
+ # not cost the roots the other paths supply.
47
+ IO_ERRORS = [Errno::ENOENT, Errno::EACCES, Errno::EISDIR].freeze
48
+
49
+ def initialize(io_boundary:, recurring_paths:)
50
+ @io_boundary = io_boundary
51
+ @recurring_paths = recurring_paths
52
+ end
53
+
54
+ # @return [Array<String>] the class names named by a `class:` key in a recurring-task entry, sorted
55
+ # and unique. NOT yet intersected with the discovered jobs — the caller does that.
56
+ def job_names
57
+ names = Set.new
58
+ @recurring_paths.each do |path|
59
+ document = load_document(path)
60
+ next unless document.is_a?(Hash)
61
+
62
+ collect_tasks(document, names)
63
+ document.each_value { |block| collect_tasks(block, names) }
64
+ end
65
+ names.to_a.sort
66
+ end
67
+
68
+ private
69
+
70
+ def load_document(path)
71
+ absolute = File.expand_path(path.to_s)
72
+ return nil unless File.file?(absolute)
73
+
74
+ contents = read_safely(absolute)
75
+ contents && parse_safely(contents)
76
+ end
77
+
78
+ def read_safely(path)
79
+ @io_boundary.read_file(path)
80
+ rescue Plugin::AccessDeniedError, *IO_ERRORS
81
+ nil
82
+ end
83
+
84
+ # `safe_load` with no permitted classes beyond Symbol: a schedule file is data, and Rigor never loads
85
+ # the Rails environment or the Solid Queue runtime to read it. `aliases: true` because YAML anchors
86
+ # are ordinary style in a hand-maintained schedule that repeats a task across environments.
87
+ def parse_safely(contents)
88
+ YAML.safe_load(contents, aliases: true, permitted_classes: [Symbol])
89
+ rescue Psych::Exception
90
+ nil
91
+ end
92
+
93
+ # A task block maps an arbitrary task NAME to an entry Hash. Only the entry's own `class:` is read —
94
+ # no recursion into the value, so an `args:` payload that happens to carry a `class` key cannot
95
+ # enter.
96
+ def collect_tasks(block, names)
97
+ return unless block.is_a?(Hash)
98
+
99
+ block.each_value do |entry|
100
+ next unless entry.is_a?(Hash)
101
+
102
+ value = fetch_either(entry, CLASS_KEY)
103
+ names << value if value.is_a?(String) && !value.empty?
104
+ end
105
+ end
106
+
107
+ def fetch_either(hash, key)
108
+ hash.fetch(key) { hash[key.to_sym] }
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -5,6 +5,8 @@ require "rigor/plugin"
5
5
  require_relative "activejob/job_index"
6
6
  require_relative "activejob/job_discoverer"
7
7
  require_relative "activejob/analyzer"
8
+ require_relative "activejob/effects"
9
+ require_relative "activejob/recurring_scan"
8
10
 
9
11
  module Rigor
10
12
  module Plugin
@@ -22,6 +24,7 @@ module Rigor
22
24
  # config:
23
25
  # job_search_paths: ["app/jobs"] # default; optional
24
26
  # job_base_classes: ["ApplicationJob", "ActiveJob::Base"] # default; optional
27
+ # recurring_paths: ["config/recurring.yml"] # default; optional
25
28
  #
26
29
  # ## Limitations (v0.1.0)
27
30
  #
@@ -34,14 +37,47 @@ module Rigor
34
37
  class Activejob < Rigor::Plugin::Base
35
38
  manifest(
36
39
  id: "activejob",
37
- version: "0.1.0",
40
+ # Bumped — publishes `:reachability_roots` for `rigor unused` (ADR-102 WD3): the jobs Solid Queue's
41
+ # recurring schedule runs by name, which no `perform_later` call site writes down.
42
+ version: "0.2.0",
38
43
  description: "Validates ActiveJob `Job.perform_later` argument arity.",
39
44
  config_schema: {
40
45
  "job_search_paths" => { kind: :array, default: ["app/jobs"] },
41
- "job_base_classes" => { kind: :array, default: %w[ApplicationJob ActiveJob::Base] }
42
- }
46
+ "job_base_classes" => { kind: :array, default: %w[ApplicationJob ActiveJob::Base] },
47
+ # `recurring_paths` — the schedule configuration {RecurringScan} reads for the reachability roots
48
+ # below. The default is Solid Queue's conventional location, the Active Job backend Rails ships by
49
+ # default from 8.0. A project that keeps its schedule elsewhere lists the file itself; these are
50
+ # file paths, not directories, because a schedule is a named document rather than a tree.
51
+ "recurring_paths" => { kind: :array, default: ["config/recurring.yml"] }
52
+ },
53
+ produces: [:reachability_roots],
54
+ # ADR-103 WD4 / WD10 (#387). The rows themselves are NOT here: they depend on
55
+ # `config.active_job.queue_adapter`, which is a project fact, so they are built in
56
+ # `#effect_attributions` below. `effect_root: "rails"` is granted because the engine bundles this
57
+ # plugin ({Rigor::Plugin::FirstParty}); a third-party plugin declaring it would open `activejob.*`.
58
+ effect_root: "rails",
59
+ effect_labels: ["rails.activejob.enqueue"],
60
+ effect_entry_points: Effects.entry_points
43
61
  )
44
62
 
63
+ # ADR-103 WD10 — the enqueue's transport, read off the project's own configuration. Overridden rather
64
+ # than declared on the manifest because the answer is per project; memoised because
65
+ # `Plugin::Registry#effect_contributions` is lazy and asks once per process, and only on a run with
66
+ # collection on — a `rigor check` with no `effects:` block never opens `config/application.rb` for this.
67
+ def effect_attributions
68
+ Effects.attributions(detected_queue_adapter)
69
+ end
70
+
71
+ def effect_edges
72
+ Effects.edges(detected_queue_adapter)
73
+ end
74
+
75
+ def detected_queue_adapter
76
+ return @detected_queue_adapter if defined?(@detected_queue_adapter)
77
+
78
+ @detected_queue_adapter = Effects.detect_adapter(io_boundary, Dir.pwd)
79
+ end
80
+
45
81
  # Cached: discovered job index. `watch:` (ADR-60 WD3) covers every `.rb` under `job_search_paths`
46
82
  # so the cache invalidates when a job is added, removed, or edited; the discoverer's in-block
47
83
  # `IoBoundary` reads are captured into the record-and-validate dependency descriptor after the
@@ -54,9 +90,38 @@ module Rigor
54
90
  ).discover
55
91
  end
56
92
 
93
+ # Cached separately from `:job_index` because the two invalidate on different files: editing a
94
+ # schedule changes which jobs are reached without touching `app/jobs` at all. The `watch:` roots the
95
+ # glob at the working directory so that CREATING a schedule file — not just editing one — is seen.
96
+ producer :recurring_jobs, watch: -> { [[".", *@recurring_paths]] } do |_params|
97
+ RecurringScan.new(
98
+ io_boundary: io_boundary,
99
+ recurring_paths: @recurring_paths
100
+ ).job_names
101
+ end
102
+
57
103
  def init(_services)
58
104
  @job_search_paths = Array(config.fetch("job_search_paths")).map(&:to_s)
59
105
  @job_base_classes = Array(config.fetch("job_base_classes")).map(&:to_s)
106
+ @recurring_paths = Array(config.fetch("recurring_paths")).map(&:to_s)
107
+ end
108
+
109
+ # ADR-102 WD3 — publishes the jobs Solid Queue's recurring schedule runs BY NAME, for `rigor unused`.
110
+ #
111
+ # The intersection is what keeps the contribution honest, exactly as in `rigor-sidekiq` and
112
+ # `rigor-pundit`. {RecurringScan} says which names the schedule WRITES; {JobIndex} says which jobs
113
+ # EXIST. A `class:` value matching no discovered job — a typo, a renamed class, a job living outside
114
+ # `job_search_paths` — is dropped rather than published, so the failure mode is a missing root (a
115
+ # candidate row a human can judge) instead of a spurious one (silence where dead code used to be).
116
+ def prepare(services)
117
+ index = producer_value(:job_index)
118
+ scheduled = producer_value(:recurring_jobs)
119
+ return if index.nil? || scheduled.nil?
120
+
121
+ roots = scheduled.select { |name| index.known?(name) }
122
+ return if roots.empty?
123
+
124
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
60
125
  end
61
126
 
62
127
  # File-level only: the load-error emission. Per-call arity validation runs over the engine-owned
@@ -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