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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Sidekiq < Rigor::Plugin::Base
8
+ # ADR-102 WD3 / #367 — which workers does this project enqueue by NAME, from schedule configuration?
9
+ #
10
+ # This is the one genuinely Sidekiq-specific reachability root, and the reason it is worth reading YAML
11
+ # for: a cron-scheduled worker is enqueued from `config/schedule.yml` as the string `"HardWorker"`, so
12
+ # the repository may contain no `HardWorker.perform_async` anywhere. The constant scan sees nothing, and
13
+ # `rigor unused` reports a worker that runs every night as dead code.
14
+ #
15
+ # Two layouts, one key. `sidekiq-cron` writes the schedule as the whole document
16
+ # (`{ name => { "cron" =>, "class" => } }`); `sidekiq-scheduler` nests the same entries under
17
+ # `config/sidekiq.yml`'s `:scheduler: :schedule:` block. Both name the worker under `class:`, and
18
+ # `class:` is the ONLY key read here.
19
+ #
20
+ # **What this refuses to read is the queue list.** `:queues:` in `sidekiq.yml` holds queue names, and a
21
+ # queue name is not a class name — inflecting `report_worker` into `ReportWorker` would manufacture a
22
+ # root out of a naming coincidence, which is precisely the over-supply this plugin declined in #350. An
23
+ # over-claiming root source silently hides real dead code (ADR-102 § Consequences), and nothing
24
+ # downstream can tell you it happened. So a queue contributes nothing, deliberately.
25
+ #
26
+ # Names are not trusted either: the caller intersects them with the workers {WorkerDiscoverer} actually
27
+ # found, so a typo or an out-of-tree class costs coverage rather than manufacturing a root, and the
28
+ # report's `matched no declaration` counter stays meaningful.
29
+ #
30
+ # Fail-soft throughout, because this reads user-authored config that Rigor does not own: an absent file,
31
+ # an unreadable one, a YAML syntax error, or a document that is not a Hash contributes nothing rather
32
+ # than raising. `rigor unused` is a report a human reads, and refusing to print it because one
33
+ # `sidekiq.yml` has a stray tab is a bad trade.
34
+ class ScheduleScan
35
+ # The key naming the worker, in both layouts. Symbol keys are accepted because `sidekiq.yml` is
36
+ # conventionally written with them (`:scheduler:`, `:schedule:`), and Psych parses `:class:` as the
37
+ # Symbol `:class`.
38
+ CLASS_KEY = "class"
39
+
40
+ # Where a schedule block hides inside a document. `[]` is the document itself — `sidekiq-cron`'s
41
+ # `schedule.yml` IS the schedule map — and the nested paths are `sidekiq-scheduler`'s, whose entries
42
+ # live under `:scheduler: :schedule:` (with the bare `:schedule:` form kept for pre-3.0 layouts).
43
+ SCHEDULE_BLOCK_KEY_PATHS = [[], %w[scheduler schedule], %w[schedule]].freeze
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:, schedule_paths:)
50
+ @io_boundary = io_boundary
51
+ @schedule_paths = schedule_paths
52
+ end
53
+
54
+ # @return [Array<String>] the class names named by a `class:` key in a schedule entry, sorted and
55
+ # unique. NOT yet intersected with the discovered workers — the caller does that.
56
+ def worker_names
57
+ names = Set.new
58
+ @schedule_paths.each do |path|
59
+ document = load_document(path)
60
+ next unless document.is_a?(Hash)
61
+
62
+ SCHEDULE_BLOCK_KEY_PATHS.each { |keys| collect_entries(dig_block(document, keys), names) }
63
+ end
64
+ names.to_a.sort
65
+ end
66
+
67
+ private
68
+
69
+ def load_document(path)
70
+ absolute = File.expand_path(path.to_s)
71
+ return nil unless File.file?(absolute)
72
+
73
+ contents = read_safely(absolute)
74
+ contents && parse_safely(contents)
75
+ end
76
+
77
+ def read_safely(path)
78
+ @io_boundary.read_file(path)
79
+ rescue Plugin::AccessDeniedError, *IO_ERRORS
80
+ nil
81
+ end
82
+
83
+ # `safe_load` with no permitted classes beyond Symbol: a schedule file is data, and Rigor never loads
84
+ # the Rails environment or the sidekiq runtime to read it. `aliases: true` because YAML anchors are
85
+ # ordinary style in a hand-maintained schedule.
86
+ def parse_safely(contents)
87
+ YAML.safe_load(contents, aliases: true, permitted_classes: [Symbol])
88
+ rescue Psych::Exception
89
+ nil
90
+ end
91
+
92
+ # Walks one of {SCHEDULE_BLOCK_KEY_PATHS} into the document. String and Symbol keys are both tried because
93
+ # `sidekiq.yml` is written with Symbol keys and `schedule.yml` with String ones.
94
+ def dig_block(document, keys)
95
+ keys.reduce(document) do |node, key|
96
+ return nil unless node.is_a?(Hash)
97
+
98
+ fetch_either(node, key)
99
+ end
100
+ end
101
+
102
+ # A schedule block maps an arbitrary job NAME to an entry Hash. Only the entry's own `class:` is read
103
+ # — no recursion into the value, so a nested option that happens to be called `class` cannot enter,
104
+ # and neither can anything under `:queues:`, whose values are Strings and Arrays rather than entries.
105
+ def collect_entries(block, names)
106
+ return unless block.is_a?(Hash)
107
+
108
+ block.each_value do |entry|
109
+ next unless entry.is_a?(Hash)
110
+
111
+ value = fetch_either(entry, CLASS_KEY)
112
+ names << value if value.is_a?(String) && !value.empty?
113
+ end
114
+ end
115
+
116
+ def fetch_either(hash, key)
117
+ hash.fetch(key) { hash[key.to_sym] }
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -4,6 +4,7 @@ require "rigor/plugin"
4
4
 
5
5
  require_relative "sidekiq/worker_index"
6
6
  require_relative "sidekiq/worker_discoverer"
7
+ require_relative "sidekiq/schedule_scan"
7
8
  require_relative "sidekiq/analyzer"
8
9
 
9
10
  module Rigor
@@ -22,6 +23,7 @@ module Rigor
22
23
  # config:
23
24
  # worker_search_paths: ["app/workers", "app/sidekiq"] # default; optional
24
25
  # worker_marker_modules: ["Sidekiq::Job", "Sidekiq::Worker"] # default; optional
26
+ # schedule_paths: ["config/schedule.yml", "config/sidekiq.yml"] # default; optional
25
27
  #
26
28
  # ## What it checks
27
29
  #
@@ -30,6 +32,11 @@ module Rigor
30
32
  # schedule and forward the rest. Mismatches emit `wrong-arity`.
31
33
  # 2. **Missing schedule** — `perform_in()` / `perform_at()` with zero arguments emit `missing-schedule`.
32
34
  #
35
+ # ## What it contributes to `rigor unused`
36
+ #
37
+ # The workers a schedule file names under `class:` and nothing else (ADR-102 WD3 / #367) — see
38
+ # {#prepare} and {ScheduleScan}.
39
+ #
33
40
  # ## Limitations (v0.1.0)
34
41
  #
35
42
  # - Direct `include` matches only against the configured marker modules. Indirect includes via a
@@ -41,12 +48,21 @@ module Rigor
41
48
  class Sidekiq < Rigor::Plugin::Base
42
49
  manifest(
43
50
  id: "sidekiq",
44
- version: "0.1.0",
51
+ # Bumped 2026-08-16 — publishes `:reachability_roots` for `rigor unused` (ADR-102 WD3): the workers
52
+ # a schedule file enqueues by name, which no `perform_async` call site writes down.
53
+ version: "0.2.0",
45
54
  description: "Validates Sidekiq `Worker.perform_async` argument arity.",
46
55
  config_schema: {
47
56
  "worker_search_paths" => { kind: :array, default: ["app/workers", "app/sidekiq"] },
48
- "worker_marker_modules" => { kind: :array, default: %w[Sidekiq::Job Sidekiq::Worker] }
49
- }
57
+ "worker_marker_modules" => { kind: :array, default: %w[Sidekiq::Job Sidekiq::Worker] },
58
+ # `schedule_paths` — the schedule configuration {ScheduleScan} reads for the reachability roots
59
+ # below. The two defaults are the conventional locations of the two schedule layouts in the wild:
60
+ # `sidekiq-cron`'s `config/schedule.yml` and `sidekiq-scheduler`'s `:scheduler: :schedule:` block
61
+ # inside `config/sidekiq.yml`. A project that keeps its schedule elsewhere lists the file itself;
62
+ # these are file paths, not directories, because a schedule is a named document rather than a tree.
63
+ "schedule_paths" => { kind: :array, default: ["config/schedule.yml", "config/sidekiq.yml"] }
64
+ },
65
+ produces: [:reachability_roots]
50
66
  )
51
67
 
52
68
  producer :worker_index, watch: -> { [[@worker_search_paths, "**/*.rb"]] } do |_params|
@@ -57,9 +73,46 @@ module Rigor
57
73
  ).discover
58
74
  end
59
75
 
76
+ # Cached separately from `:worker_index` because the two invalidate on different files: editing a
77
+ # schedule changes which workers are reached without touching `app/workers` at all. The `watch:` roots
78
+ # the glob at the working directory so that CREATING a schedule file — not just editing one — is seen.
79
+ producer :scheduled_workers, watch: -> { [[".", *@schedule_paths]] } do |_params|
80
+ ScheduleScan.new(
81
+ io_boundary: io_boundary,
82
+ schedule_paths: @schedule_paths
83
+ ).worker_names
84
+ end
85
+
60
86
  def init(_services)
61
87
  @worker_search_paths = Array(config.fetch("worker_search_paths")).map(&:to_s)
62
88
  @worker_marker_modules = Array(config.fetch("worker_marker_modules")).map(&:to_s)
89
+ @schedule_paths = Array(config.fetch("schedule_paths")).map(&:to_s)
90
+ end
91
+
92
+ # ADR-102 WD3 — publishes the workers a schedule file enqueues BY NAME, for `rigor unused`.
93
+ #
94
+ # `MyWorker.perform_async(...)` writes the worker's name as an ordinary constant, so the report's scan
95
+ # already records that edge and a root would add nothing. A worker named only as the string
96
+ # `class: "MyWorker"` in `config/schedule.yml` is the opposite case: it runs every night and the
97
+ # constant appears nowhere, so it reads as dead.
98
+ #
99
+ # The intersection is what keeps the contribution honest, exactly as in `rigor-pundit`. {ScheduleScan}
100
+ # says which names the schedule WRITES; {WorkerDiscoverer} says which workers EXIST. A `class:` value
101
+ # matching no discovered worker — a typo, a renamed class, a job living outside `worker_search_paths` —
102
+ # is dropped rather than published, so the failure mode is a missing root (a candidate row a human can
103
+ # judge) instead of a spurious one (silence where dead code used to be).
104
+ #
105
+ # Publishing the discovered worker set instead would have been one line and is what this refuses to do:
106
+ # "a file exists under `app/workers`" is not evidence that anything enqueues it.
107
+ def prepare(services)
108
+ index = producer_value(:worker_index)
109
+ scheduled = producer_value(:scheduled_workers)
110
+ return if index.nil? || scheduled.nil?
111
+
112
+ roots = scheduled.select { |name| index.known?(name) }
113
+ return if roots.empty?
114
+
115
+ services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
63
116
  end
64
117
 
65
118
  # File-level only: the load-error emission. The per-call arity validation runs over the engine-owned
@@ -5,6 +5,7 @@ module Rigor
5
5
  def self?.build_node_collectors: (String path, untyped scope_index) -> Hash[Symbol, untyped]
6
6
  def self?.node_collector_driver: (Hash[Symbol, untyped] collectors) -> untyped
7
7
  def self?.shadow_verify_converged_collectors: (String path, untyped root, untyped scope_index, Hash[Symbol, untyped]? collectors) -> void
8
+ def self?.filter_suppressed: (Array[Diagnostic] diagnostics, comments: untyped, disabled_rules: Array[String]) -> Array[Diagnostic]
8
9
  end
9
10
 
10
11
  class FactStore
@@ -163,6 +163,8 @@ module Rigor
163
163
  module ScopeIndexer
164
164
  def self?.index: (untyped root, default_scope: Scope) -> Hash[untyped, Scope]
165
165
  def self?.build_declaration_overrides: (untyped root) -> Hash[untyped, Type::t]
166
+ def self?.build_declaration_artifacts: (untyped root) -> [Hash[untyped, Type::t], Hash[String, Type::t]]
167
+ def self?.build_in_source_constants: (untyped root, Scope default_scope) -> Hash[String, Type::t]
166
168
  def self?.record_declarations: (untyped node, Array[String] qualified_prefix, Hash[untyped, Type::t] identity_table, Hash[String, Type::t] discovered) -> void
167
169
  def self?.discovered_classes_for_paths: (Array[String] paths, ?buffer: untyped) -> Hash[String, Type::t]
168
170
  def self?.discovered_def_index_for_paths: (Array[String] paths, ?buffer: untyped) -> Hash[Symbol, untyped]
@@ -47,6 +47,12 @@ module Rigor
47
47
 
48
48
  def self?.parse_conforms_to_annotation: (String? string) -> String?
49
49
 
50
+ # ADR-103 #383 — the effect-envelope reader. `annotations` is any list of RBS annotation nodes
51
+ # (`RBS::Definition::Method#annotations`, or a declaration / member's own `#annotations`); the result
52
+ # is a `Rigor::Effects::Envelope`, or nil when the list carries neither purity spelling.
53
+ def self?.pure_annotation?: (String? string) -> bool
54
+ def self?.read_effect_envelope: (untyped annotations, owner_key: String, ?source: Symbol, ?registry: untyped, ?reporter: untyped) -> untyped?
55
+
50
56
  class ParamOverride
51
57
  attr_reader param_name: Symbol
52
58
  attr_reader type: Type::t
data/sig/rigor/scope.rbs CHANGED
@@ -19,6 +19,7 @@ module Rigor
19
19
  attr_reader method_chain_narrowings: Hash[ChainKey, Type::t]
20
20
  attr_reader source_path: String?
21
21
  attr_reader struct_fold_safe_locals: Set[Symbol]
22
+ attr_reader opaque_block_self: bool
22
23
 
23
24
  # ADR-53 Track A — the seed-time discovery tables live on the
24
25
  # DiscoveryIndex; Scope keeps per-table readers as delegates.
@@ -90,6 +91,8 @@ module Rigor
90
91
  def with_source_path: (String? path) -> Scope
91
92
  def with_struct_fold_safe: (Set[Symbol] locals) -> Scope
92
93
  def struct_fold_safe?: (String | Symbol name) -> bool
94
+ def entering_opaque_block: () -> Scope
95
+ def opaque_block_self?: () -> bool
93
96
  def with_discovery: (DiscoveryIndex index) -> Scope
94
97
  def local: (String | Symbol name) -> Type::t?
95
98
  def ivar: (String | Symbol name) -> Type::t?
@@ -123,6 +126,7 @@ module Rigor
123
126
  def singleton_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
124
127
  def user_def_site_for: (String | Symbol class_name, String | Symbol method_name) -> String?
125
128
  def top_level_def_for: (String | Symbol method_name) -> untyped?
129
+ def bindable_top_level_def_for: (String | Symbol method_name) -> untyped?
126
130
  def toplevel?: () -> bool
127
131
  def discovered_method_visibility: (String | Symbol class_name, String | Symbol method_name) -> Symbol?
128
132
  def superclass_of: (String | Symbol class_name) -> String?
data/sig/rigor.rbs CHANGED
@@ -14,8 +14,12 @@ module Rigor
14
14
  def self.load: (?String? path) -> Configuration
15
15
  def self.discover: () -> String?
16
16
  def self.load_with_includes: (String path, ?visited: Set[String]) -> Hash[String, untyped]
17
- def initialize: (?Hash[String, untyped] data) -> void
17
+ def initialize: (?Hash[String, untyped] data, ?bool effects_key_present) -> void
18
18
  def to_h: () -> Hash[String, untyped]
19
+ # ADR-103 WD13 — the `effects:` block, nil when the key was absent. Presence is the opt-in.
20
+ attr_reader effects: Hash[String, untyped]?
21
+ def effects_enabled?: () -> bool
22
+ def with_effects_enabled: () -> Rigor::Configuration
19
23
  end
20
24
 
21
25
  class CLI
@@ -58,6 +62,10 @@ module Rigor
58
62
  # `node` / `location` are Prism values, received as `untyped`).
59
63
  def self.from_node: (untyped node, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
60
64
  def self.from_location: (untyped location, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
65
+ # `from_message_loc` / `from_name_loc` forward `**` to `from_location`, so their keyword surface is
66
+ # `from_location`'s; only the positional node differs.
67
+ def self.from_message_loc: (untyped node, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
68
+ def self.from_name_loc: (untyped node, path: String, message: String, ?severity: Symbol, ?rule: String?, ?source_family: String, ?receiver_type: untyped, ?method_name: untyped, ?project_definition_site: untyped) -> Rigor::Analysis::Diagnostic
61
69
  end
62
70
 
63
71
  class Result
@@ -121,6 +129,18 @@ module Rigor
121
129
  def analysis_file_set: (?Array[String] paths) -> Array[String]
122
130
  def file_dependents: () -> Hash[String, untyped]
123
131
  def prepare_project_scan: (?paths: Array[String]) -> untyped
132
+ # ADR-103 — the propagated effect graph and the merged per-file collections behind it. A report
133
+ # surface: never a diagnostic, empty when the configuration carries no `effects:` block. Typed
134
+ # `untyped` for the same reason `cache_store` is — `Rigor::Effects` is not sig-covered yet
135
+ # (`spec/rigor/public_api_drift_spec.rb`), and a named reference would raise `RBS::UnknownTypeName`.
136
+ def effect_table: () -> untyped
137
+ def effect_collection: () -> untyped
138
+ # #381 — `{ "Class#m" => [path] }`, which the snapshot's `reach:` globs match against. Same
139
+ # `untyped` reasoning as its two siblings above.
140
+ def effect_sources: () -> untyped
141
+ # #387 — the loaded plugins' compiled effect contributions, read by `rigor effects` so the snapshot's
142
+ # vocabulary is the one the collection window scanned under. Same `untyped` reasoning again.
143
+ def effect_plugin_facts: () -> untyped
124
144
  end
125
145
  end
126
146
  end
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: rigor-unused-adjudicate
3
+ description: |
4
+ Find dead code in a Ruby project with `rigor unused` — establish what the report can see on THIS project first, then adjudicate every row before proposing any deletion. Use this whenever someone asks to find or remove dead code, unused classes, unused constants, or "code nobody calls", whenever they ask what a `rigor unused` report means or which rows are safe to delete, and whenever a dead-code cleanup, codebase inventory, or legacy audit comes up — even if they never say "rigor". The report is a review queue and not a defect list; on an adjudicated corpus target only 4 of 57 rows were genuinely dead, so acting on it directly produces mostly wrong deletions. NOT for deleting a specific class you already know is dead, and NOT for `rigor check` diagnostics (those are ordinary type errors).
5
+ license: MPL-2.0
6
+ metadata:
7
+ version: 0.2.0
8
+ homepage: https://github.com/rigortype/rigor
9
+ ---
10
+
11
+ # Adjudicating `rigor unused`
12
+
13
+ `rigor unused` answers *which project classes and modules does no reachable
14
+ code name?* — not *what can I delete*. Closing that gap is the work, and the
15
+ manual chapter has the method:
16
+
17
+ ```sh
18
+ rigor docs 18-removing-dead-code
19
+ ```
20
+
21
+ Read it before adjudicating anything. It carries the false-positive shapes with
22
+ their measured frequencies, the pre-deletion checklist, and how to report the
23
+ result. This skill exists for the part the chapter cannot do for you: **work out
24
+ what the report can and cannot see on this particular project, before you trust
25
+ a single row.**
26
+
27
+ Do that first, because every check below changes how the output should be read —
28
+ and skipping one is how a live class ends up on a deletion list.
29
+
30
+ ## Establish the ground first
31
+
32
+ Run the report and answer these against the project in front of you.
33
+
34
+ **Are framework roots actually supplied?** The summary prints
35
+ `roots: N (M from plugins, …)`. On a framework application `0 from plugins`
36
+ means nothing is naming your controllers, jobs or policies, so most of the
37
+ report is noise. Stop and fix the plugin configuration — `rigor docs
38
+ 07-plugins` — rather than adjudicating hundreds of rows.
39
+
40
+ **Does the project ship signatures?** If `signature_paths:` is configured, also
41
+ run with it emptied and compare:
42
+
43
+ ```sh
44
+ rigor unused # normal
45
+ rigor unused --config /tmp/no-sig.yml # a copy of your config with signature_paths: []
46
+ ```
47
+
48
+ Adjudicate the union. On one application the signature-free run surfaced three
49
+ genuinely dead classes that the default run never showed. Rows that appear only
50
+ without signatures are still worth checking — signatures reference classes, and
51
+ a reference the report counts is not always one a human would.
52
+
53
+ **What do `paths:` actually cover?** Only Ruby under those paths is analysed.
54
+ Views are the common gap: a helper called from `app/views/**/*.erb` has no Ruby
55
+ caller at all, and will land in the report looking unused. Before believing any
56
+ helper or presenter row, grep the view tree for it.
57
+
58
+ **Is the git history usable?** The chapter's checklist uses "when did this file
59
+ last change" as evidence. In a shallow clone, or one whose history is all
60
+ dependency bumps, that signal is empty — notice it and say so rather than
61
+ reporting a bot commit as the file's age.
62
+
63
+ ## Then hand off to the chapter
64
+
65
+ With that established, follow the chapter. Two things worth holding onto as you
66
+ go, because they are what the report's own design is built around:
67
+
68
+ **Framework conventions and configuration reach code without naming it.** The
69
+ chapter lists the shapes; the ones that recur hardest are a class named as a
70
+ *string* in `config/*.yml` (a recurring-job schedule, a queue definition), a
71
+ convention that derives one name from another (`FooHelper` from
72
+ `FooController`, a decorator from a model, a join model from
73
+ `has_many :speakers_talks`), and a registration DSL called inside the class
74
+ body. None of these appear as a constant anywhere.
75
+
76
+ **Wrong rows cascade.** A class used only by a wrongly-classified class follows
77
+ it into the report. When a row turns out to be live, re-check what it
78
+ references before treating those rows as independent findings.
79
+
80
+ ## Two habits that keep the answer honest
81
+
82
+ **A search that finds nothing proves nothing until it has found something.**
83
+ Before concluding a name appears nowhere, run the same search against a name you
84
+ know is used. Silence from a broken command and silence from dead code look
85
+ identical.
86
+
87
+ **Over-claiming hides dead code; under-claiming does not.** If you cannot settle
88
+ a row, leave it in the report and say you could not settle it. A row left on the
89
+ list is visible to a human; a row you dismissed on a hunch is invisible to
90
+ everyone.