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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Actionpack < Rigor::Plugin::Base
8
+ # rigor-actionpack's effect contract (ADR-103 WD10 / WD14; design note § 11.2; issue #387).
9
+ #
10
+ # A controller action's effects are mostly writes to the response and to per-request state, and the
11
+ # vocabulary distinguishes them because a reviewer does: `rails.response.write` is "this action
12
+ # answers the request", `rails.session.write` is "this action changes who the user is logged in as".
13
+ #
14
+ # ## `mutate.self`, not `io`
15
+ #
16
+ # `render` and `redirect_to` do not write to a socket. They set `@_response_body` and the status on
17
+ # the controller instance; Rack writes it later, outside any project method. So the honest label is
18
+ # `mutate.self` plus the framework meaning — and an envelope forbidding `io` in a service object is
19
+ # not violated by a helper that happens to call `render_to_string`.
20
+ #
21
+ # `render` additionally keeps a **taint**: the template is not an effect unit yet (ADR-103 WD11 /
22
+ # issue #392), so what the view does is genuinely unknown and the summary says so rather than
23
+ # pretending the action stops at the `render` line.
24
+ #
25
+ # ## The self-path rows
26
+ #
27
+ # `session[:user_id] = id` is `[]=` on the result of a receiver-less `session`, and nothing types
28
+ # that result. The `self.session` spelling matches the receiver expression as written, scoped by
29
+ # `within:` to classes whose project ancestry reaches `ActionController::Base` — so a `session`
30
+ # method on some unrelated project class is not mistaken for this one.
31
+ module Effects
32
+ CONTROLLER = "ActionController::Base"
33
+
34
+ RESPONSE = ["mutate.self", "rails.response.write"].freeze
35
+ SESSION_WRITE = ["mutate", "rails.session.write"].freeze
36
+ SESSION_READ = ["io", "rails.session.read"].freeze
37
+ COOKIE_WRITE = ["mutate", "rails.cookie.write"].freeze
38
+ FLASH_WRITE = ["mutate", "rails.flash.write"].freeze
39
+
40
+ # Response writers that say everything about themselves.
41
+ RESPONSE_WRITERS = %w[redirect_to redirect_back redirect_back_or_to head].freeze
42
+
43
+ # The render family. Same labels, plus the `template-not-analysed` taint: what the controller does
44
+ # is fully stated, and what the TEMPLATE does is unknown until views are effect units (ADR-103
45
+ # WD11 / issue #392). A summary that stopped at the `render` line and read exhaustive would be
46
+ # the one genuinely misleading row in the whole Rails layer.
47
+ RENDERERS = %w[render render_to_string render_to_body].freeze
48
+
49
+ # The cookie jars a Rails app writes through.
50
+ COOKIE_JARS = ["self.cookies", "self.cookies.signed", "self.cookies.encrypted",
51
+ "self.cookies.permanent"].freeze
52
+
53
+ module_function
54
+
55
+ def attributions
56
+ response_rows + file_rows + session_rows + cookie_rows + flash_rows
57
+ end
58
+
59
+ def response_rows
60
+ RESPONSE_WRITERS.map do |selector|
61
+ EffectAttribution.new(
62
+ receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
63
+ why: "sets the response on the controller instance — Rack writes the socket later, outside " \
64
+ "any project method, so this is `mutate.self` and deliberately not `io`"
65
+ )
66
+ end + render_rows
67
+ end
68
+
69
+ def render_rows
70
+ RENDERERS.map do |selector|
71
+ EffectAttribution.new(
72
+ receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
73
+ taint: "template-not-analysed",
74
+ why: "sets the response body from a template. The controller half is fully stated; the " \
75
+ "template's own effects are unknown until views become effect units, and the taint " \
76
+ "is how the summary says so rather than reading exhaustive"
77
+ )
78
+ end
79
+ end
80
+
81
+ # `send_file` streams from disk; `send_data` does not.
82
+ def file_rows
83
+ [
84
+ EffectAttribution.new(
85
+ receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true,
86
+ why: "sets the response body from an in-memory string"
87
+ ),
88
+ EffectAttribution.new(
89
+ receiver: CONTROLLER, method: :send_file, labels: RESPONSE + ["io.fs.read"], discharge: true,
90
+ why: "sets the response AND reads the named file off disk"
91
+ )
92
+ ]
93
+ end
94
+
95
+ def session_rows
96
+ [
97
+ EffectAttribution.new(
98
+ receiver: "self.session", method: :[]=, labels: SESSION_WRITE, within: CONTROLLER,
99
+ discharge: true,
100
+ why: "writes per-request state whose store may be a cookie, a cache or the database"
101
+ ),
102
+ EffectAttribution.new(
103
+ receiver: "self.session", method: :delete, labels: SESSION_WRITE, within: CONTROLLER,
104
+ discharge: true, why: "same store, same write"
105
+ ),
106
+ EffectAttribution.new(
107
+ receiver: "self.session", method: :[], labels: SESSION_READ, within: CONTROLLER,
108
+ discharge: true,
109
+ why: "reads the session store — `io` because a cache- or database-backed store really does " \
110
+ "go out to fetch it"
111
+ ),
112
+ EffectAttribution.new(
113
+ receiver: CONTROLLER, method: :reset_session, labels: SESSION_WRITE, discharge: true,
114
+ why: "discards the whole session — the logout write"
115
+ )
116
+ ]
117
+ end
118
+
119
+ def cookie_rows
120
+ COOKIE_JARS.flat_map do |jar|
121
+ %i[[]= delete].map do |selector|
122
+ EffectAttribution.new(
123
+ receiver: jar, method: selector, labels: COOKIE_WRITE, within: CONTROLLER, discharge: true,
124
+ why: "queues a Set-Cookie header on the response — state that outlives the request"
125
+ )
126
+ end
127
+ end
128
+ end
129
+
130
+ def flash_rows
131
+ ["self.flash", "self.flash.now"].flat_map do |jar|
132
+ %i[[]= alert= notice=].map do |selector|
133
+ EffectAttribution.new(
134
+ receiver: jar, method: selector, labels: FLASH_WRITE, within: CONTROLLER, discharge: true,
135
+ why: "writes the flash, which rides the session into the next request"
136
+ )
137
+ end
138
+ end
139
+ end
140
+
141
+ def entry_points
142
+ [
143
+ EffectEntryPoints.new(
144
+ name: "rails-controllers", globs: ["app/controllers/**/*.rb"],
145
+ why: "controller actions — the request entry points; nothing in the project calls them"
146
+ )
147
+ ]
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -3,6 +3,7 @@
3
3
  require "rigor/plugin"
4
4
 
5
5
  require_relative "actionpack/analyzer"
6
+ require_relative "actionpack/effects"
6
7
  require_relative "actionpack/controller_discoverer"
7
8
  require_relative "actionpack/controller_index"
8
9
 
@@ -69,7 +70,14 @@ module Rigor
69
70
  consumes: [
70
71
  { plugin_id: "rails-routes", name: :helper_table, optional: true },
71
72
  { plugin_id: "activerecord", name: :model_index, optional: true }
72
- ]
73
+ ],
74
+ # ADR-103 WD10 / WD14 (#387) — see {Effects} for what each row is and why.
75
+ effect_root: "rails",
76
+ effect_labels: %w[
77
+ rails.response.write rails.session.read rails.session.write rails.cookie.write rails.flash.write
78
+ ],
79
+ effect_attributions: Effects.attributions,
80
+ effect_entry_points: Effects.entry_points
73
81
  )
74
82
 
75
83
  # Phase 2 cached producer — the controller index built from `controller_search_paths`. `watch:`
@@ -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