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
@@ -26,6 +26,15 @@ comments (it works the same way against GitLab, Gerrit, Bitbucket, and Gitea
26
26
  — see the [`rigor-ci-setup`](../../../skills/rigor-ci-setup/SKILL.md) skill).
27
27
  All run Rigor the same way — only the output format and publish step differ.
28
28
 
29
+ Every template ends with a **commented-out `rigor effects check` step**.
30
+ Uncomment it once the project has an `effects:` block and a committed
31
+ `.rigor-effects.yml`, and CI will fail when a branch changes what the code
32
+ *does* — a job that starts talking to the network, a presenter that starts
33
+ querying. It ships commented because `effects check` exits `1` when the
34
+ snapshot file is absent, which is exactly what you want on a project that
35
+ has one and pure noise on a project that does not. The workflow around it
36
+ is [chapter 19, "Effect labels"](../19-effect-labels.md).
37
+
29
38
  ## Other runners (generic recipe)
30
39
 
31
40
  On any CI system, the four steps are: provision Ruby 4.0, install
@@ -64,6 +64,11 @@ plugins:
64
64
  - A brand-new view file does not invalidate the cached index until
65
65
  something the mailer file touches changes (the read-tracking
66
66
  trade-off).
67
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused).**
68
+ `MyMailer.welcome(...)` names the mailer as an ordinary constant,
69
+ which the report already records. Rooting every class under
70
+ `app/mailers` instead would mark a mailer nothing sends reachable
71
+ forever on no evidence.
67
72
 
68
73
  ## Plugin internals
69
74
 
@@ -36,8 +36,47 @@ plugins:
36
36
  config:
37
37
  job_search_paths: ["app/jobs"] # default
38
38
  job_base_classes: ["ApplicationJob", "ActiveJob::Base"] # default
39
+ recurring_paths: ["config/recurring.yml"] # default
39
40
  ```
40
41
 
42
+ `recurring_paths` are the schedule *files* — not directories —
43
+ behind the reachability roots below. The default is where Solid
44
+ Queue's recurring schedule conventionally lives; list your own path
45
+ if you keep it elsewhere.
46
+
47
+ ## Job roots for `rigor unused`
48
+
49
+ Solid Queue is the Active Job backend Rails ships by default from
50
+ 8.0, and a **recurring task names its job by string**:
51
+
52
+ ```yaml
53
+ # config/recurring.yml
54
+ production:
55
+ send_reminder:
56
+ class: "SendReminderJob"
57
+ schedule: "*/3 * * * *"
58
+ ```
59
+
60
+ That job runs every three minutes and there is no `perform_later`
61
+ for it anywhere, so the constant scan sees nothing and
62
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) reports live
63
+ production code as possibly dead. This plugin supplies the jobs your
64
+ recurring schedule names, so they drop out of the candidate list.
65
+
66
+ **Every environment block is read**, not just `production:` — a job
67
+ scheduled in `staging:` is still live code. A flat, environment-less
68
+ document works too.
69
+
70
+ Only `class:` is read. A `command:` entry is inline Ruby
71
+ (`command: "SomeModel.cleanup"`), and parsing a constant out of an
72
+ arbitrary snippet would root a class on a guess, so it supplies
73
+ nothing. A `class:` naming a job the plugin never discovered is
74
+ dropped rather than published: a typo costs you a root instead of
75
+ quietly hiding a dead job.
76
+
77
+ The schedule is read with `YAML.safe_load`. Nothing boots Rails and
78
+ nothing loads Solid Queue.
79
+
41
80
  ## Limitations
42
81
 
43
82
  - **Direct-superclass match only.** `class WelcomeJob < BaseJob`
@@ -48,6 +87,14 @@ plugins:
48
87
  - **Positional arity only.** Required keyword arguments are
49
88
  recorded by the discoverer but not yet validated at the call
50
89
  site.
90
+ - **Roots only from a recurring schedule.** A job is rooted for
91
+ `rigor unused` when `config/recurring.yml` names it, and never for
92
+ existing under `app/jobs`: `MyJob.perform_later` names the job as
93
+ an ordinary constant the report already records, and rooting every
94
+ discovered job would mark an orphaned one reachable forever on no
95
+ evidence. A schedule loaded from Ruby rather than from a file under
96
+ `recurring_paths` supplies nothing, by the same "read only what is
97
+ written" rule.
51
98
 
52
99
  ## Plugin internals
53
100
 
@@ -54,6 +54,22 @@ plugins:
54
54
  # Minitest projects: ["test/factories"]
55
55
  ```
56
56
 
57
+ ## Factory references for `rigor unused`
58
+
59
+ `factory :user, class: "Admin::User"` names a class as a string, and
60
+ a bare `factory :user` relies on FactoryBot constantizing the factory
61
+ name. Neither is a constant reference, so
62
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) cannot see them
63
+ and would report a factoried model as dead.
64
+
65
+ This plugin supplies those model classes — as **test-role
66
+ references**, not as roots. A class reached only through a factory
67
+ therefore leaves the candidate list and appears under **Reachable
68
+ only from test code** instead. That is deliberate: a model kept alive
69
+ by its factory and its spec and nothing else is dead production code
70
+ with a live test, and rooting factories would have hidden exactly
71
+ that finding.
72
+
57
73
  ## Limitations
58
74
 
59
75
  - **Literal arguments only** — `FactoryBot.create(name)` with a
@@ -48,8 +48,34 @@ plugins:
48
48
  config:
49
49
  policy_search_paths: ["app/policies"] # default
50
50
  policy_base_classes: ["ApplicationPolicy"] # default
51
+ authorization_call_paths: ["app/controllers"] # default
51
52
  ```
52
53
 
54
+ `authorization_call_paths` is where the plugin looks for the
55
+ `authorize` / `policy` / `policy_scope` calls behind the reachability
56
+ roots below. Widen it if you also authorize from `app/graphql` or
57
+ `app/services`; it defaults narrow because the walk is a parse per
58
+ file on every run.
59
+
60
+ ## Policy roots for `rigor unused`
61
+
62
+ `authorize @post` runs `PostPolicy#update?`, and the name
63
+ `PostPolicy` is never written down. So without help,
64
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) reports every
65
+ policy in your app as possibly dead. This plugin supplies the
66
+ policies your code actually authorizes against, so they drop out of
67
+ the candidate list.
68
+
69
+ It publishes the policies **an authorization call names**, not every
70
+ class under `policy_search_paths`. A policy nothing authorizes
71
+ against stays in the report, which is the answer you wanted to see:
72
+ a root source that claims more than it can show hides real dead code
73
+ without telling you.
74
+
75
+ A policy the plugin cannot attribute to a call — a namespaced
76
+ `Admin::PostPolicy`, a record argument it cannot read — stays a
77
+ candidate rather than being guessed at.
78
+
53
79
  ## Limitations
54
80
 
55
81
  - **Direct-superclass match only.** `class AdminPostPolicy <
@@ -83,6 +83,44 @@ The parsed helper table is published as the `:helper_table`
83
83
  cross-plugin fact (ADR-9), which `rigor-actionpack` consumes to
84
84
  validate helper calls inside controllers.
85
85
 
86
+ ### Controller roots for `rigor unused`
87
+
88
+ The same parse also yields the **controller classes** your routes
89
+ dispatch to, published as the `:reachability_roots` fact that
90
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) seeds its
91
+ reachability report with.
92
+
93
+ This matters because Rails reaches a controller by *name* at request
94
+ time. Nothing in your code references `Admin::UsersController`, so
95
+ without route knowledge every controller in the app reads as
96
+ unreferenced — on Mastodon that was 233 spurious candidates.
97
+
98
+ Controller names are composed the way Rails composes them:
99
+ `resources :users` inside `namespace :admin` is
100
+ `Admin::UsersController`; a singular `resource :profile` is served by
101
+ the plural `ProfilesController`; `scope module:` and a `module:`
102
+ option on a resource extend the namespace; `to: "posts#index"`,
103
+ `controller:`, and the hashrocket `get "help" => "help#show"` form
104
+ are all read. Acronyms declared in
105
+ `config/initializers/inflections.rb` are applied, so
106
+ `scope module: :activitypub` resolves to `ActivityPub::…`.
107
+
108
+ Two properties follow from parsing rather than booting, and are the
109
+ reason to prefer this over reading a running app's route table:
110
+
111
+ - **No Rails runtime is loaded.**
112
+ - **A route under a conditional is still a root.**
113
+ `get "/beta", to: "beta#index" if ENV["ENABLE_BETA"]` does not
114
+ appear in a booted app's route table unless the condition happened
115
+ to hold; both branches are visible to a static read.
116
+
117
+ **Not covered as roots** (deliberately, rather than half-covered):
118
+ controllers remapped by `devise_for … controllers:`, by
119
+ `use_doorkeeper … controllers:`, or served through a mounted Grape
120
+ API. Those mappings name controllers the plugin does not resolve, so
121
+ they may appear as `rigor unused` candidates even though they are
122
+ routed. Helper-name recognition for all three is unaffected.
123
+
86
124
  ## Limitations
87
125
 
88
126
  - **Statically unfoldable route definitions.** Helpers produced by
@@ -92,9 +130,10 @@ validate helper calls inside controllers.
92
130
  `unknown-helper`. Record those in a baseline, or
93
131
  `# rigor:disable` the line.
94
132
  - **Project-custom inflections** declared in
95
- `config/initializers/inflections.rb` are not yet ingested
133
+ `config/initializers/inflections.rb` are not yet fully ingested
96
134
  (ADR-39 slice 3); the standard ActiveSupport inflections are
97
- covered.
135
+ covered, and `inflect.acronym` declarations are read for
136
+ controller-class composition only.
98
137
 
99
138
  ## Plugin internals
100
139
 
@@ -62,6 +62,11 @@ The plugin has no configuration knobs.
62
62
  / `have_received` (overlap engine constant / undefined-method rules).
63
63
  - **Literal arguments only** — a status passed via a variable or method
64
64
  call is not statically checkable, so it's accepted silently.
65
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused)**,
66
+ for the reason [`rigor-rspec`](rigor-rspec.md#limitations) gives: a
67
+ spec's constant reference is already recorded with the `test` role,
68
+ and rooting it would delete the report's "reachable only from tests"
69
+ answer.
65
70
 
66
71
  ## Plugin internals
67
72
 
@@ -67,6 +67,14 @@ to enable project-wide alongside non-spec files.
67
67
  not flagged.
68
68
  - Constant validation (`RSpec.describe SomeClass`) is the
69
69
  engine's job, not this plugin's.
70
+ - **No roots for [`rigor unused`](../02-cli-reference.md#rigor-unused).**
71
+ `RSpec.describe User` is already recorded as a reference, stamped
72
+ with the `test` role because it came from a spec. Publishing spec
73
+ references as roots would strip that role, promote every
74
+ spec-referenced class to production-reachable, and delete the
75
+ report's **Reachable only from test code** section — the row that
76
+ tells you a class is dead production code with a live test. The
77
+ ordinary handling is the correct one.
70
78
 
71
79
  ## Related plugins
72
80
 
@@ -50,11 +50,61 @@ plugins:
50
50
  config:
51
51
  worker_search_paths: ["app/workers", "app/sidekiq"] # default
52
52
  worker_marker_modules: ["Sidekiq::Job", "Sidekiq::Worker"] # default
53
+ schedule_paths: # default
54
+ - "config/schedule.yml"
55
+ - "config/sidekiq.yml"
53
56
  ```
54
57
 
55
58
  The default `worker_marker_modules` covers both modern Sidekiq
56
59
  (`Sidekiq::Job`, since 6.3) and the legacy `Sidekiq::Worker`.
57
60
 
61
+ `schedule_paths` are the schedule *files* — not directories — behind
62
+ the reachability roots below. The defaults are where the two schedule
63
+ layouts conventionally live; list your own path if you keep the
64
+ schedule elsewhere.
65
+
66
+ ## Worker roots for `rigor unused`
67
+
68
+ A cron-scheduled worker is enqueued **by name from YAML**, so
69
+ `NightlyReportWorker` can appear nowhere in your code. Without help,
70
+ [`rigor unused`](../02-cli-reference.md#rigor-unused) reports a job
71
+ that runs every night as possibly dead. This plugin supplies the
72
+ workers your schedule names, so they drop out of the candidate list:
73
+
74
+ ```yaml
75
+ # config/schedule.yml (sidekiq-cron) config/sidekiq.yml (sidekiq-scheduler)
76
+ nightly_report: :scheduler:
77
+ cron: "0 3 * * *" :schedule:
78
+ class: "NightlyReportWorker" nightly_report:
79
+ every: "1h"
80
+ class: "NightlyReportWorker"
81
+ ```
82
+
83
+ It reads the `class:` key and nothing else. In particular a **queue
84
+ name is not a class name**: the `:queues:` list in `sidekiq.yml`
85
+ supplies no roots, because inflecting `report_worker` into
86
+ `ReportWorker` would root a worker on a naming coincidence. And a
87
+ `class:` naming a worker the plugin never discovered is dropped rather
88
+ than published, so a typo costs you a root instead of quietly hiding a
89
+ dead worker.
90
+
91
+ `MyWorker.perform_async` still supplies nothing — it is an ordinary
92
+ constant reference the report already sees. Neither does the mere
93
+ existence of a file under `app/workers`: a worker nothing enqueues
94
+ stays in the report, which is the answer you wanted.
95
+
96
+ The schedule is read with `YAML.safe_load`; nothing boots.
97
+ A missing, unreadable or malformed file is skipped without affecting
98
+ the rest of the run.
99
+
100
+ If your workers include a project concern (`include ApplicationWorker`)
101
+ rather than `Sidekiq::Job` directly, add that concern to
102
+ `worker_marker_modules` — otherwise the plugin discovers no workers,
103
+ every scheduled name is dropped, and you get no roots at all. GitLab's
104
+ `config/schedule.yml` names 111 workers, all of them concern-based:
105
+ with the default markers that is 0 roots, and with `ApplicationWorker`
106
+ added it is 100.
107
+
58
108
  ## Limitations
59
109
 
60
110
  - **Direct `include` only.** A worker that mixes in a custom concern
@@ -68,6 +118,10 @@ The default `worker_marker_modules` covers both modern Sidekiq
68
118
  `perform_at` is consumed as the schedule regardless of its type.
69
119
  - **Chained `set(...)`** (`Worker.set(queue: "low").perform_async(...)`)
70
120
  is validated as a normal call; `set`'s own options are not checked.
121
+ - **Schedule roots are read from `class:` only.** `sidekiq-cron`'s
122
+ alternative `klass:` spelling, and a schedule built in Ruby with
123
+ `Sidekiq::Cron::Job.load_from_hash!`, supply no roots — the worker
124
+ stays a `rigor unused` candidate rather than being guessed at.
71
125
 
72
126
  ## Plugin internals
73
127
 
@@ -44,6 +44,28 @@ module Rigor
44
44
  # return, used in value context. Authored `:warning`, resolved `:off` by every profile and promoted to
45
45
  # `:warning` only by the `use-of-void-value` bleeding-edge feature.
46
46
  RULE_VALUE_USE_VOID = "static.value-use.void"
47
+ # ADR-103 WD8 / #383 — the first `effect.*` id: a method whose PROVEN effect labels are not
48
+ # subsumed by the envelope its author declared (`%a{pure}` / `%a{rigor:v1:effect ...}`). Opt-in
49
+ # twice over — the `effects:` block enables collection, and the envelope is the author's own
50
+ # directive — so it is never unsolicited.
51
+ RULE_EFFECT_ENVELOPE_EXCEEDED = "effect.envelope-exceeded"
52
+ # ADR-103 WD1 / #384 — the paired vocabulary diagnostic. An unknown label degrades the whole tag
53
+ # to ⊤, which is silent by construction; this is what keeps that fail-open reading honest. It
54
+ # fires only where label intent is evident (`Effects::LabelIntent`'s four signals), so a project
55
+ # opening its own vocabulary is never nagged, and it is gated by the same `effects.check` switch
56
+ # as its sibling: opting into envelope enforcement is exactly what turns on the diagnostic that
57
+ # says an envelope stopped enforcing.
58
+ RULE_EFFECT_UNKNOWN_LABEL = "effect.unknown-label"
59
+ # ADR-103 WD1 / WD14 / #386 — the inherited-bound reading. An override performs, or itself
60
+ # declares, an effect the envelope written on the method it overrides does not admit. Implementations
61
+ # may be purer than the bound they inherit, never less pure; both-sides-authored in the ADR-35 sense,
62
+ # so nothing fires unless someone wrote an envelope on the ancestor.
63
+ RULE_EFFECT_LISKOV_WIDENED = "effect.liskov-widened"
64
+ # ADR-103 WD13 commitment 1 / #384 — the residual. A project whose RBS carries `%a{pure}` /
65
+ # `%a{rigor:v1:effect …}` but no `effects:` block gets ONE `:info` per run saying so. An
66
+ # annotation must never turn collection on by itself (that would be a project-wide cost cliff
67
+ # nobody asked for), and it must equally never be silently inert.
68
+ RULE_EFFECT_ANNOTATIONS_UNCHECKED = "effect.annotations-unchecked"
47
69
 
48
70
  ALL_RULES = [
49
71
  RULE_UNDEFINED_METHOD,
@@ -72,7 +94,11 @@ module Rigor
72
94
  RULE_SUPPRESSION_UNKNOWN_RULE,
73
95
  RULE_SUPPRESSION_EMPTY,
74
96
  RULE_SUPPRESSION_UNKNOWN_MARKER,
75
- RULE_VALUE_USE_VOID
97
+ RULE_VALUE_USE_VOID,
98
+ RULE_EFFECT_ENVELOPE_EXCEEDED,
99
+ RULE_EFFECT_LISKOV_WIDENED,
100
+ RULE_EFFECT_UNKNOWN_LABEL,
101
+ RULE_EFFECT_ANNOTATIONS_UNCHECKED
76
102
  ].freeze
77
103
 
78
104
  # Backward-compat alias table (ADR-8 § "Backward compatibility"). Existing user code with
@@ -102,7 +128,7 @@ module Rigor
102
128
 
103
129
  # Family wildcard — a `<family>` token in a suppression comment or `disable:` list disables every rule
104
130
  # whose canonical id starts with `<family>.`. Per ADR-8 § "1".
105
- RULE_FAMILIES = %w[call flow assert dump def suppression static].freeze
131
+ RULE_FAMILIES = %w[call flow assert dump def suppression static effect].freeze
106
132
 
107
133
  # Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and
108
134
  # reporter-level diagnostics such as `rbs_extended.unsatisfied-conformance`,
@@ -5,6 +5,9 @@ require_relative "incremental"
5
5
  require_relative "plugin_fact_fingerprint"
6
6
  require_relative "../cache/file_digest"
7
7
  require_relative "../cache/incremental_snapshot"
8
+ require_relative "../effects/file_collection"
9
+ require_relative "../effects/identity"
10
+ require_relative "../effects/propagator"
8
11
  require_relative "../inference/scope_indexer"
9
12
 
10
13
  module Rigor
@@ -105,6 +108,7 @@ module Rigor
105
108
  # and a missing invalidation edge is impossible by construction — the reason this is a table diff
106
109
  # and not the caller→callee edge recording #204 first sketched.
107
110
  @param_table = {}
111
+ reset_effect_state
108
112
  # ADR-88 WD1 — the plugin fact-surface digest computed for THIS invocation (nil until a
109
113
  # `#run_incremental` pass runs / a plugin-free project) and the reporting flags a caller (the CLI
110
114
  # banner + `--cache-stats`) reads after `#run_incremental`. `@last_runner` is the analysis runner the
@@ -122,6 +126,32 @@ module Rigor
122
126
  @analyzed
123
127
  end
124
128
 
129
+ # ADR-103 WD12 / issue #382 — the session's effect graph: the fixpoint over the merged whole,
130
+ # recomputed on every ask. This is the invariant that makes per-file collection reuse sound — the
131
+ # closure is never partially reused, only its per-file *inputs* are, so a leaf edit whose new label
132
+ # reaches a caller in an unchanged file still shows up in that caller's `reach`.
133
+ def effect_table
134
+ Effects::Propagator.propagate(
135
+ effect_collection, discharge: Effects::Discharge.new(@configuration.effects_tolerated)
136
+ )
137
+ end
138
+
139
+ # The merged direct summaries the table above closes over.
140
+ def effect_collection
141
+ Effects::FileCollection.merge_all(sorted_effect_collections)
142
+ end
143
+
144
+ # Where each unit was defined — the same shape (and the same purpose: the snapshot's `reach:` globs)
145
+ # as `Runner#effect_sources`, answered from the session's own per-file collections.
146
+ def effect_sources
147
+ sorted_effect_collections.each_with_object({}) do |collection, out|
148
+ path = collection.path
149
+ next if path.nil?
150
+
151
+ collection.summaries.each_key { |key| (out[key] ||= []) << path }
152
+ end
153
+ end
154
+
125
155
  # Full baseline analysis with recording. Returns the run's diagnostics; populates the in-process cache
126
156
  # + dependency state.
127
157
  def baseline
@@ -135,6 +165,9 @@ module Rigor
135
165
  # ADR-67 WD6c lift — the seed table the runner's own pre-pass computed ({} when the gate is off).
136
166
  # Reading it back, rather than computing it here, keeps the baseline single-collect.
137
167
  @param_table = runner.param_inferred_types
168
+ # ADR-103 WD13 / #382 — a baseline collects every file, so its collections are the whole world.
169
+ @effect_collections = runner.effect_collections_by_path
170
+ @effects_identity = current_effects_identity
138
171
  @cache = per_file(diagnostics)
139
172
  @digests = @analyzed.to_h { |path| [path, pack_digest(path)] }
140
173
  diagnostics
@@ -417,10 +450,18 @@ module Rigor
417
450
  # (types with no fingerprint surface) make the snapshot un-reusable.
418
451
  if restored
419
452
  restore(restored)
453
+ # ADR-103 WD13 / #382 — the effects sidecar joins the same reuse decision, for a structurally
454
+ # identical reason: a snapshot whose summaries were collected under a different vocabulary /
455
+ # catalogue / `effects:` block restores as empty, and a recheck only re-collects the changed
456
+ # closure, so the merged table would be missing every unchanged file. A full baseline is the
457
+ # honest answer — it is what "recompute effects" means when collecting requires analysing, and
458
+ # it is paid on the first collecting run and on an identity change, never with collection off.
459
+ # Asked BEFORE the recheck, which re-stamps the session with the current identity.
460
+ effects_reusable = effects_reuse_permitted?(restored)
420
461
  result = recheck
421
462
  adopt_plugin_fact_fingerprint
422
- reuse = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
423
- if reuse
463
+ fact_reusable = @plugin_fact_reusable.reusable_against?(restored.plugin_fact_digest)
464
+ if fact_reusable && effects_reusable
424
465
  diagnostics = result.diagnostics
425
466
  warm = true
426
467
  # ADR-87 WD3 — a warm recheck that changed nothing leaves the session state byte-equivalent to the
@@ -428,10 +469,12 @@ module Rigor
428
469
  # A cold baseline always persists — there was no valid snapshot to reuse.
429
470
  skip_save = result.no_change?
430
471
  else
431
- # The fact surface moved (a plugin sig/catalog edit) or a plugin is opaque: the cached-served
432
- # files the recheck merged may be stale, so re-analyze the whole tree. The current fact-surface
472
+ # The fact surface moved (a plugin sig/catalog edit), a plugin is opaque, or the effects
473
+ # identity moved: the cached-served files the recheck merged may be stale (or, for effects, a
474
+ # partial collection cannot be closed), so re-analyze the whole tree. The current fact-surface
433
475
  # digest (from the recheck runner) is unchanged by the re-analysis, so it is kept for the save.
434
- @fact_surface_invalidated = true
476
+ # Only a genuine fact-surface reason sets the reporting flag the CLI banner reads.
477
+ @fact_surface_invalidated = true unless fact_reusable
435
478
  diagnostics = baseline
436
479
  warm = false
437
480
  skip_save = false
@@ -459,6 +502,12 @@ module Rigor
459
502
 
460
503
  private
461
504
 
505
+ # Path-sorted, so the fold is reproducible whatever order the files were absorbed in (the same reason
506
+ # `Runner#effect_collections` sorts).
507
+ def sorted_effect_collections
508
+ @effect_collections.sort_by { |path, _| path.to_s }.map(&:last)
509
+ end
510
+
462
511
  # ADR-88 WD1 — capture this invocation's fact-surface fingerprint (from the last analysis runner) onto the
463
512
  # reporting ivars + the `@plugin_fact_reusable` decision object.
464
513
  def adopt_plugin_fact_fingerprint
@@ -509,6 +558,12 @@ module Rigor
509
558
  @class_decls = payload.class_decls || {}
510
559
  @return_summaries = payload.return_summaries || {}
511
560
  @param_table = payload.param_table || {} # ADR-67 WD6c lift — the seeds the cache was built under.
561
+ # ADR-103 WD13 / #382 — the effects sidecar rides its own identity, so a snapshot whose summaries
562
+ # were collected under a different vocabulary / catalogue / `effects:` block restores as EMPTY
563
+ # rather than as stale rows. {#run_incremental} turns that emptiness into a full baseline, because
564
+ # a partial re-collection cannot be closed into a whole-project fixpoint.
565
+ @effects_identity = payload.effects_identity
566
+ @effect_collections = effect_collections_reusable?(payload) ? payload.effect_collections.dup : {}
512
567
  @symbol_dependents = Incremental.invert_symbols(@symbol_sources)
513
568
  @ancestry_dependents = Incremental.invert(@ancestry_sources)
514
569
  @negative_dependents = Incremental.invert(@missing)
@@ -522,10 +577,71 @@ module Rigor
522
577
  class_decls: @class_decls, seed_bundles: @seed_bundles,
523
578
  plugin_fact_digest: @plugin_fact_digest,
524
579
  return_summaries: marshal_safe_return_summaries,
525
- param_table: marshal_safe_param_table
580
+ param_table: marshal_safe_param_table,
581
+ effect_collections: marshal_safe_effect_collections,
582
+ effects_identity: @effects_identity
526
583
  )
527
584
  end
528
585
 
586
+ # ADR-103 WD13 / issue #382 — the per-file effect collections the session serves unchanged files
587
+ # from, and the effects identity they were collected under. Empty / nil when collection is off, which
588
+ # is every run that does not carry an `effects:` block. The PROPAGATED table is never kept here:
589
+ # {#effect_table} re-runs the fixpoint over the merged whole on every ask, because a leaf's summary
590
+ # reaches every caller and there is no per-file invalidation for a whole-graph closure.
591
+ def reset_effect_state
592
+ @effect_collections = {}
593
+ @effects_identity = nil
594
+ end
595
+
596
+ # Whether the effects half of a restored snapshot permits reuse. Collection off is vacuously true —
597
+ # the session keeps no collections, writes none, and the pre-#382 reuse decision stands unchanged.
598
+ def effects_reuse_permitted?(payload)
599
+ !@configuration.effects_enabled? || effect_collections_reusable?(payload)
600
+ end
601
+
602
+ # ADR-103 WD13 / #382 — whether a restored payload's effect collections may be served this run:
603
+ # collection is on, and they were collected under the identity this run computes. Collection being
604
+ # OFF answers false and costs nothing — the session then keeps no collections, writes none, and is
605
+ # the pre-#382 session in every observable way.
606
+ def effect_collections_reusable?(payload)
607
+ return false unless @configuration.effects_enabled?
608
+ return false if payload.effects_identity.nil? || !payload.effect_collections.is_a?(Hash)
609
+
610
+ payload.effects_identity == current_effects_identity
611
+ end
612
+
613
+ # The effects identity for THIS run, memoised: it digests a YAML catalogue and the `effects:` block,
614
+ # and a recheck asks for it on both the restore and the absorb side. nil when collection is off, so a
615
+ # non-collecting run never loads the catalogue at all.
616
+ #
617
+ # **Deliberately plugin-blind**, unlike the ADR-45 whole-run effects slot, whose descriptor carries
618
+ # {Effects::PluginFacts#digest} (#387). The two sides of this comparison sit on opposite sides of the
619
+ # run: the restore asks before any plugin is loaded and the save asks after, so folding the plugin
620
+ # facts in here would compare a blind digest against a sighted one and miss every single time. The
621
+ # bound it leaves — a plugin upgrade that moves a row does not invalidate an `--incremental`
622
+ # snapshot's effect collections — is recorded in `docs/internal-spec/effect-summaries.md`; the
623
+ # primary (whole-run) path has no such hole, and `--incremental` is opt-in.
624
+ def current_effects_identity
625
+ return nil unless @configuration.effects_enabled?
626
+
627
+ @current_effects_identity ||= Effects::Identity.digest(configuration: @configuration)
628
+ end
629
+
630
+ # The same Marshal-clean guard {#marshal_safe_return_summaries} applies, for the same reason: a
631
+ # snapshot save must never raise. A dropped collection makes that file's summaries absent from the
632
+ # next run's merged table, which understates its effects — so it is dropped only when it genuinely
633
+ # will not serialise, and {FileCollection} is built to (the fork pool marshals one per file already).
634
+ def marshal_safe_effect_collections
635
+ return {} unless @configuration.effects_enabled?
636
+
637
+ @effect_collections.each_with_object({}) do |(path, collection), safe|
638
+ Marshal.dump(collection)
639
+ safe[path] = collection
640
+ rescue StandardError
641
+ next
642
+ end
643
+ end
644
+
529
645
  # ADR-89 WD2 — the return summaries filtered to Marshal-clean entries. A summary's `keys` hold live
530
646
  # `Type` objects; the common carriers (Nominal, Constant, Union, shapes, Dynamic) Marshal, but a type
531
647
  # holding a live AST node would raise and abort the WHOLE snapshot save (a cache must never break a
@@ -568,6 +684,21 @@ module Rigor
568
684
  end
569
685
  absorb_dependency_graph(runner)
570
686
  refresh_return_summaries(runner, analyze_set)
687
+ refresh_effect_collections(runner, analyze_set)
688
+ end
689
+
690
+ # ADR-103 WD13 / #382 — the effects analogue of {#refresh_return_summaries}, and the same soundness
691
+ # order: drop every re-analyzed file's collection first, then fold this run's harvest in. A file the
692
+ # recheck did NOT analyze keeps the collection the snapshot carried, which is exactly the reuse the
693
+ # slot exists for; a re-analyzed file that produced nothing (a parse failure, a file of constants)
694
+ # correctly ends up with nothing rather than with its pre-edit summaries.
695
+ def refresh_effect_collections(runner, analyze_set)
696
+ return unless @configuration.effects_enabled?
697
+
698
+ analyzed = analyze_set.to_set
699
+ @effect_collections.reject! { |path, _| analyzed.include?(path) }
700
+ @effect_collections.merge!(runner.effect_collections_by_path)
701
+ @effects_identity = current_effects_identity
571
702
  end
572
703
 
573
704
  # ADR-89 WD2 — replace the behavioural summaries of every re-analyzed file with THIS run's harvest: drop
@@ -599,6 +730,9 @@ module Rigor
599
730
  @symbol_fingerprints.delete(path)
600
731
  # ADR-89 WD2 — drop every behavioural summary a removed file provided (keys are `[path, symbol]`).
601
732
  @return_summaries.reject! { |(summary_path, _symbol), _| summary_path == path }
733
+ # ADR-103 WD13 / #382 — and its effect collection, so a deleted file stops contributing summaries
734
+ # and edges to the merged table the fixpoint closes.
735
+ @effect_collections.delete(path)
602
736
  # @class_decls is wholesale-replaced from the (removed-excluding)
603
737
  # pre-pass in absorb_dependency_graph, and is frozen, so no delete.
604
738
  end