rigortype 0.1.18 → 0.2.0

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 (210) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -69,9 +69,9 @@ module Rigor
69
69
  # True when at least one discovered channel uses a
70
70
  # dynamic stream registration. The analyzer treats
71
71
  # this as "we can't be sure any literal name is
72
- # missing" and downgrades unknown-stream from
73
- # `:warning` to `:info` (or drops it entirely;
74
- # current behaviour: skip warnings).
72
+ # missing" and skips the `unknown-stream` warning
73
+ # entirely absence of a literal match doesn't prove
74
+ # the name is wrong.
75
75
  def any_dynamic_streams?
76
76
  @entries.any?(&:dynamic_streams)
77
77
  end
@@ -41,7 +41,7 @@ module Rigor
41
41
  # `stream_for record`) — the absence of a literal
42
42
  # match doesn't prove absence.
43
43
  #
44
- # ## Limitations (v0.1.0)
44
+ # ## Limitations
45
45
  #
46
46
  # - **Direct-superclass match only.** Indirect
47
47
  # inheritance (`AdminChannel < BaseChannel <
@@ -51,8 +51,8 @@ module Rigor
51
51
  # ActionCable actions are invoked from JS via
52
52
  # `subscription.perform("action_name", data)`; we
53
53
  # don't analyse JS so the action-method index is
54
- # currently informational only (future cross-plugin
55
- # handoff to a hypothetical JS-side analyzer).
54
+ # informational only (deferred: cross-plugin handoff
55
+ # to a JS-side analyzer).
56
56
  # - **`broadcast_to` arity isn't checked.** The method
57
57
  # takes any record + any data hash; there's no
58
58
  # useful arity envelope.
@@ -70,7 +70,12 @@ module Rigor
70
70
  }
71
71
  )
72
72
 
73
- producer :channel_index do |_params|
73
+ # `watch:` covers every `.rb` file under the channel search paths
74
+ # so the cache invalidates when channels are added, removed, or
75
+ # edited (ADR-60 WD3). The dependency descriptor is recorded after
76
+ # the discoverer runs, so the `io_boundary` reads inside it are
77
+ # captured too.
78
+ producer :channel_index, watch: -> { [[@channel_search_paths, "**/*.rb"]] } do |_params|
74
79
  ChannelDiscoverer.new(
75
80
  io_boundary: io_boundary,
76
81
  search_paths: @channel_search_paths,
@@ -81,54 +86,33 @@ module Rigor
81
86
  def init(_services)
82
87
  @channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
83
88
  @channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
84
- @channel_index = nil
85
- @load_error = nil
86
89
  end
87
90
 
88
91
  # File-level only: the load-error emission. Per-call broadcast
89
92
  # validation runs over the engine-owned walk via the node_rule
90
93
  # below (ADR-37). The channel index is lazily loaded + memoised by
91
- # channel_index_or_nil, shared by both surfaces.
94
+ # `producer_value`, shared by both surfaces.
92
95
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
93
- index = channel_index_or_nil
94
- return [load_error_diagnostic(path)] if index.nil? && @load_error
96
+ index = producer_value(:channel_index)
97
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:channel_index)
95
98
 
96
99
  []
97
100
  end
98
101
 
99
102
  node_rule Prism::CallNode do |node, _scope, path|
100
- index = channel_index_or_nil
103
+ index = producer_value(:channel_index)
101
104
  next [] if index.nil? || index.empty?
102
105
 
103
- Analyzer.violations_for(call_node: node, channel_index: index).map do |violation|
104
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
105
- end
106
+ diagnostics_for(Analyzer.violations_for(call_node: node, channel_index: index), path: path, node: node)
106
107
  end
107
108
 
108
109
  private
109
110
 
110
- def channel_index_or_nil
111
- return @channel_index if @channel_index
112
-
113
- # Pass an explicit descriptor covering every `.rb` file
114
- # under the configured channel search paths so the cache
115
- # invalidates when channels are added, removed, or edited.
116
- # Without it the auto-built descriptor depends on the
117
- # `IoBoundary`'s in-process read history — empty on the
118
- # first call of a fresh process, so warm cache hits would
119
- # serve stale `ChannelIndex` data when project files have
120
- # changed between sessions.
121
- descriptor = glob_descriptor(@channel_search_paths, "**/*.rb")
122
- @channel_index = cache_for(:channel_index, params: {}, descriptor: descriptor).call
123
- rescue StandardError => e
124
- @load_error = "rigor-actioncable: failed to discover channels: #{e.class}: #{e.message}"
125
- nil
126
- end
127
-
128
111
  def load_error_diagnostic(path)
112
+ error = producer_error(:channel_index)
129
113
  Rigor::Analysis::Diagnostic.new(
130
114
  path: path, line: 1, column: 1,
131
- message: @load_error,
115
+ message: "rigor-actioncable: failed to discover channels: #{error.class}: #{error.message}",
132
116
  severity: :warning,
133
117
  rule: "load-error"
134
118
  )
@@ -301,19 +301,11 @@ module Rigor
301
301
  [entry.method_name, entry]
302
302
  end
303
303
 
304
- # Merge in actions from include'd modules. The
305
- # discoverer pre-collected every module's defs as
306
- # `module_actions` keyed by fully-qualified module
307
- # name. We resolve each include against that map —
308
- # tries the full include name first, then walks down
309
- # the class's lexical chain looking for a nested
310
- # match (e.g. `Emails::Issues` inside `class Notify`
311
- # at top-level resolves to top-level `Emails::Issues`).
312
- # Includes we cannot resolve are silently skipped;
313
- # the per-mailer `unresolved_includes?` predicate
314
- # below (consumed by the analyzer) downgrades
315
- # `unknown-action` to silence when any include is
316
- # unresolved.
304
+ # Merge actions from include'd modules (pre-collected
305
+ # in `module_actions` keyed by fully-qualified name).
306
+ # Unresolvable includes are tracked; `unresolved_includes?`
307
+ # (consumed by the analyzer) downgrades `unknown-action`
308
+ # to silence when any include remains unresolved.
317
309
  unresolved_includes = []
318
310
  includes.each do |include_name|
319
311
  inc_actions = module_actions[include_name]
@@ -65,7 +65,13 @@ module Rigor
65
65
  }
66
66
  )
67
67
 
68
- producer :mailer_index do |_params|
68
+ # `watch:` covers every mailer class under `mailer_search_paths`
69
+ # AND every view template under `views_root` (ADR-60 WD3) — a
70
+ # newly-added view a mailer references must invalidate the index,
71
+ # and `view_exists?` failures the producer never records would
72
+ # otherwise be invisible to the dependency descriptor.
73
+ producer :mailer_index,
74
+ watch: -> { [[@mailer_search_paths, "**/*.rb"], [@views_root, "**/*"]] } do |_params|
69
75
  MailerDiscoverer.new(
70
76
  io_boundary: io_boundary,
71
77
  search_paths: @mailer_search_paths,
@@ -78,8 +84,6 @@ module Rigor
78
84
  @mailer_search_paths = Array(config.fetch("mailer_search_paths")).map(&:to_s)
79
85
  @mailer_base_classes = Array(config.fetch("mailer_base_classes")).map(&:to_s)
80
86
  @views_root = config.fetch("views_root").to_s
81
- @mailer_index = nil
82
- @load_error = nil
83
87
  end
84
88
 
85
89
  # File-level: load-error + the missing-view check (anchored on the
@@ -88,46 +92,22 @@ module Rigor
88
92
  # arity) runs over the engine-owned walk via the node_rule below
89
93
  # (ADR-37). The mailer index is lazily loaded + memoised, shared.
90
94
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
91
- index = mailer_index_or_nil
92
- return [load_error_diagnostic(path)] if index.nil? && @load_error
95
+ index = producer_value(:mailer_index)
96
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:mailer_index)
93
97
  return [] if index.nil? || index.empty?
94
98
 
95
99
  missing_view_diagnostics(path, index)
96
100
  end
97
101
 
98
102
  node_rule Prism::CallNode do |node, _scope, path|
99
- index = mailer_index_or_nil
103
+ index = producer_value(:mailer_index)
100
104
  next [] if index.nil? || index.empty?
101
105
 
102
- Analyzer.violations_for(call_node: node, mailer_index: index).map do |violation|
103
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
104
- end
106
+ diagnostics_for(Analyzer.violations_for(call_node: node, mailer_index: index), path: path, node: node)
105
107
  end
106
108
 
107
109
  private
108
110
 
109
- def mailer_index_or_nil
110
- return @mailer_index if @mailer_index
111
-
112
- # Two-glob descriptor: every mailer class under
113
- # `mailer_search_paths` AND every view template under
114
- # `views_root`. Without explicit enumeration the cache
115
- # invalidates only on files the `IoBoundary` has already
116
- # read in the current process — empty on the first call
117
- # of a fresh process, so warm hits would serve stale
118
- # `MailerIndex` data after mailers are added / removed or
119
- # view templates are added (`view_exists?` failures aren't
120
- # recorded, so the auto-built descriptor cannot detect a
121
- # newly-added view).
122
- mailer_d = glob_descriptor(@mailer_search_paths, "**/*.rb")
123
- view_d = glob_descriptor([@views_root], "**/*")
124
- descriptor = Rigor::Cache::Descriptor.compose(mailer_d, view_d)
125
- @mailer_index = cache_for(:mailer_index, params: {}, descriptor: descriptor).call
126
- rescue StandardError => e
127
- @load_error = "rigor-actionmailer: failed to discover mailers: #{e.class}: #{e.message}"
128
- nil
129
- end
130
-
131
111
  # Anchors `missing-view` diagnostics on the mailer file
132
112
  # itself: when the file currently being analysed is the
133
113
  # mailer's source file, emit one diagnostic per missing
@@ -158,9 +138,10 @@ module Rigor
158
138
  end
159
139
 
160
140
  def load_error_diagnostic(path)
141
+ error = producer_error(:mailer_index)
161
142
  Rigor::Analysis::Diagnostic.new(
162
143
  path: path, line: 1, column: 1,
163
- message: @load_error,
144
+ message: "rigor-actionmailer: failed to discover mailers: #{error.class}: #{error.message}",
164
145
  severity: :warning,
165
146
  rule: "load-error"
166
147
  )
@@ -33,9 +33,8 @@ module Rigor
33
33
  # Phase 2 — filter-chain DSL methods. Each takes a
34
34
  # variadic list of filter names (Symbols / Strings) plus
35
35
  # optional `only:` / `except:` / `if:` / `unless:`
36
- # modifiers. The validation key is the filter NAMES; the
37
- # modifiers are accepted but their action-name argument
38
- # is not yet validated (Phase 2.5).
36
+ # modifiers. Only the filter NAMES are validated; the
37
+ # `only:`/`except:` action-name arguments are not (deferred).
39
38
  FILTER_DSL_METHODS = %i[
40
39
  before_action after_action around_action
41
40
  skip_before_action skip_after_action skip_around_action
@@ -43,18 +42,14 @@ module Rigor
43
42
  ].freeze
44
43
 
45
44
  # Phase 3 — render-target template extensions checked in
46
- # priority order. The first six cover the templating
47
- # engines used by the projects this plugin is regularly
48
- # exercised against: ERB (Rails default — `.html.erb`,
49
- # `.text.erb`), HAML (Mastodon, Solidus admin
50
- # `.html.haml`), Slim, and JSON (`.json.jbuilder` plus a
51
- # raw `.json.erb` for hand-rolled API responses). When a
52
- # template exists under any of these extensions, the
53
- # missing-template diagnostic stays silent.
54
- # Configurable extension list is queued — see the
55
- # `external-author plugin SKILL` track (v0.2.0). For now
56
- # this set is wide enough to cover the surveyed real-world
57
- # projects without leaking FPs.
45
+ # priority order. Covers the engines used by surveyed
46
+ # projects: ERB (Rails default `.html.erb`, `.text.erb`),
47
+ # HAML (Mastodon, Solidus admin — `.html.haml`), Slim, and
48
+ # JSON (`.json.jbuilder` plus `.json.erb` for hand-rolled API
49
+ # responses). When a template exists under any of these
50
+ # extensions, the missing-template diagnostic stays silent.
51
+ # A configurable extension list is deferred; this set is wide
52
+ # enough to cover surveyed real-world projects without FPs.
58
53
  RENDER_TEMPLATE_EXTENSIONS = %w[
59
54
  .html.erb
60
55
  .text.erb
@@ -167,8 +162,7 @@ module Rigor
167
162
  # list (looked up via the model_index fact published by
168
163
  # `rigor-activerecord`). Calls whose `:require` argument is a
169
164
  # non-literal Symbol are passed through; namespaced models
170
- # (`params.require(:admin_user)` → `Admin::User`) are deferred to a
171
- # Phase 1.5 follow-up.
165
+ # (`params.require(:admin_user)` → `Admin::User`) are deferred.
172
166
  #
173
167
  # @param call_node [Prism::Node]
174
168
  # @param model_index [Hash{String => Hash}]
@@ -68,17 +68,14 @@ module Rigor
68
68
  class Actionpack < Rigor::Plugin::Base
69
69
  manifest(
70
70
  id: "actionpack",
71
- # Bumped 2026-06-02 — ADR-37 node_rule migration. The four
72
- # phases (helper / filter / render / strong-params) now run
73
- # per-call over the engine-owned walk instead of the
74
- # hand-rolled `diagnostics_for_file` traversal; the enclosing
75
- # controller is read from the node-rule `NodeContext` ancestors.
76
- # Nested-module qualification is preserved — a
77
- # `module Admin; class DomainBlocksController; end` file still
71
+ # ADR-37: the four phases (helper / filter / render /
72
+ # strong-params) run per-call over the engine-owned walk;
73
+ # the enclosing controller is read from the node-rule
74
+ # `NodeContext` ancestors. Nested-module qualification is
75
+ # preserved `module Admin; class DomainBlocksController`
78
76
  # resolves as `Admin::DomainBlocksController` (matching the
79
- # `ControllerDiscoverer`), so render paths
80
- # (`admin/domain_blocks/new`) and filter-chain validation on
81
- # nested controllers are unchanged.
77
+ # `ControllerDiscoverer`), so render paths and filter-chain
78
+ # validation on nested controllers are correct.
82
79
  version: "0.8.0",
83
80
  description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
84
81
  config_schema: {
@@ -91,27 +88,22 @@ module Rigor
91
88
  ]
92
89
  )
93
90
 
94
- # Phase 2 cached producer — the controller index built
95
- # from `controller_search_paths`. The IoBoundary records
96
- # a `FileEntry` digest for every file the discoverer
97
- # reads, so the cache invalidates when any controller
98
- # file changes.
99
- producer :controller_index do |_params|
91
+ # Phase 2 cached producer — the controller index built from
92
+ # `controller_search_paths`. `watch:` (ADR-60 WD3) covers every
93
+ # `.rb` file under those roots so the cache invalidates when a
94
+ # controller is added, removed, or edited; the discoverer's
95
+ # in-block `io_boundary` reads are captured into the dependency
96
+ # descriptor too, so no explicit priming is needed.
97
+ producer :controller_index, watch: -> { [[@controller_search_paths, "**/*.rb"]] } do |_params|
100
98
  ControllerDiscoverer.new(
101
99
  io_boundary: io_boundary,
102
100
  search_paths: @controller_search_paths
103
101
  ).discover
104
102
  end
105
103
 
106
- def init(services)
107
- @services = services
104
+ def init(_services)
108
105
  @controller_search_paths = Array(config.fetch("controller_search_paths")).map(&:to_s)
109
106
  @view_search_paths = Array(config.fetch("view_search_paths")).map(&:to_s)
110
- @helper_table = nil
111
- @helper_table_resolved = false
112
- @controller_index = nil
113
- @model_index_value = nil
114
- @model_index_resolved = false
115
107
  end
116
108
 
117
109
  # ADR-37 — the four Action Pack phases run per-call over the
@@ -123,16 +115,16 @@ module Rigor
123
115
  # The filter / render phases read the enclosing controller from the
124
116
  # node-rule `NodeContext` ancestors (its fifth block argument).
125
117
 
126
- # Phase 4 — route-helper consumption.
118
+ # Phase 4 — route-helper consumption. `:helper_table` is
119
+ # rigor-rails-routes's published fact (ADR-9), read lazily via
120
+ # `read_fact`.
127
121
  node_rule Prism::CallNode do |node, _scope, path|
128
122
  next [] unless controller_file?(path)
129
123
 
130
- table = helper_table
124
+ table = read_fact(plugin_id: "rails-routes", name: :helper_table)
131
125
  next [] if table.nil? || table.empty?
132
126
 
133
- Analyzer.helper_violations_for(call_node: node, helper_table: table).map do |v|
134
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
135
- end
127
+ diagnostics_for(Analyzer.helper_violations_for(call_node: node, helper_table: table), path: path, node: node)
136
128
  end
137
129
 
138
130
  # Phase 2 — filter-chain validation. Skips silently when the
@@ -141,12 +133,13 @@ module Rigor
141
133
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
142
134
  next [] unless controller_file?(path)
143
135
 
144
- index = controller_index_or_nil
136
+ index = producer_value(:controller_index)
145
137
  next [] if index.nil? || index.empty?
146
138
 
147
- Analyzer.filter_violations_for(call_node: node, ancestors: context.ancestors, controller_index: index).map do |v|
148
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
149
- end
139
+ diagnostics_for(
140
+ Analyzer.filter_violations_for(call_node: node, ancestors: context.ancestors, controller_index: index),
141
+ path: path, node: node
142
+ )
150
143
  end
151
144
 
152
145
  # Phase 3 — render-target validation against the configured
@@ -157,12 +150,13 @@ module Rigor
157
150
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
158
151
  next [] unless controller_file?(path)
159
152
 
160
- Analyzer.render_violations_for(
161
- call_node: node, ancestors: context.ancestors, path: path,
162
- view_search_roots: @view_search_paths, controller_index: controller_index_or_nil
163
- ).map do |v|
164
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
165
- end
153
+ diagnostics_for(
154
+ Analyzer.render_violations_for(
155
+ call_node: node, ancestors: context.ancestors, path: path,
156
+ view_search_roots: @view_search_paths, controller_index: producer_value(:controller_index)
157
+ ),
158
+ path: path, node: node
159
+ )
166
160
  end
167
161
 
168
162
  # Phase 1 — strong-parameter validation. Reads the `:model_index`
@@ -173,74 +167,14 @@ module Rigor
173
167
  node_rule Prism::CallNode do |node, _scope, path|
174
168
  next [] unless controller_file?(path)
175
169
 
176
- index = model_index
170
+ index = read_fact(plugin_id: "activerecord", name: :model_index)
177
171
  next [] if index.nil? || index.empty?
178
172
 
179
- Analyzer.permit_violations_for(call_node: node, model_index: index).map do |v|
180
- diagnostic(node, path: path, location: v.location, message: v.message, severity: v.severity, rule: v.rule)
181
- end
173
+ diagnostics_for(Analyzer.permit_violations_for(call_node: node, model_index: index), path: path, node: node)
182
174
  end
183
175
 
184
176
  private
185
177
 
186
- def controller_index_or_nil
187
- return @controller_index if @controller_index
188
-
189
- # Read project source first so the IoBoundary's
190
- # FileEntry digests get captured into the descriptor
191
- # before `cache_for` snapshots it (mirrors
192
- # rigor-rails-routes / rigor-pundit's pattern).
193
- prime_io_boundary_for_index
194
- @controller_index = cache_for(:controller_index, params: {}).call
195
- rescue StandardError
196
- nil
197
- end
198
-
199
- def prime_io_boundary_for_index
200
- @controller_search_paths.each do |root|
201
- absolute = File.expand_path(root)
202
- next unless File.directory?(absolute)
203
-
204
- Dir.glob(File.join(absolute, "**", "*.rb")).each do |path|
205
- io_boundary.read_file(path)
206
- rescue Plugin::AccessDeniedError, Errno::ENOENT
207
- nil
208
- end
209
- end
210
- end
211
-
212
- # Lazily resolves the helper table from the cross-plugin
213
- # fact store. The cache is per-run because the runner
214
- # builds a fresh `FactStore` per invocation; memoizing on
215
- # the plugin instance saves the per-file `read` while
216
- # still picking up a freshly-published table on the next
217
- # `bundle exec rigor check` run.
218
- def helper_table
219
- return @helper_table if @helper_table_resolved
220
-
221
- @helper_table = @services.fact_store.read(
222
- plugin_id: "rails-routes", name: :helper_table
223
- )
224
- @helper_table_resolved = true
225
- @helper_table
226
- end
227
-
228
- # Phase 1 — lazily reads the cross-plugin :model_index
229
- # fact from rigor-activerecord. The cache is per-run
230
- # because the runner builds a fresh FactStore per
231
- # invocation; memoizing on the plugin instance saves the
232
- # per-file read while still picking up a freshly
233
- # published index on the next `bundle exec rigor check`.
234
- def model_index
235
- return @model_index_value if @model_index_resolved
236
-
237
- @model_index_value = @services.fact_store.read(
238
- plugin_id: "activerecord", name: :model_index
239
- )
240
- @model_index_resolved = true
241
- @model_index_value
242
- end
243
-
244
178
  def controller_file?(path)
245
179
  @controller_search_paths.any? do |root|
246
180
  # The runner may pass `path` as either an absolute
@@ -12,8 +12,9 @@ module Rigor
12
12
  # (`Float::INFINITY` for the upper bound when `*args`
13
13
  # is present). `keyword_required` lists any required
14
14
  # keyword arguments — Active Job supports keyword args
15
- # but they're rare in user code, so the analyzer only
16
- # validates positional arity for v0.1.0.
15
+ # but they're rare in user code, so the analyzer
16
+ # validates positional arity only (keyword arity
17
+ # validation is deferred).
17
18
  class JobIndex
18
19
  Entry = Data.define(:class_name, :min_arity, :max_arity, :keyword_required) do
19
20
  # Flexible-friendly textual form of the arity for
@@ -48,12 +48,12 @@ module Rigor
48
48
  }
49
49
  )
50
50
 
51
- # Cached: discovered job index. The producer reads every
52
- # file under `job_search_paths` via the trusted
53
- # `IoBoundary`; the descriptor's auto-collected
54
- # `FileEntry` digests invalidate the cache when any of
55
- # those files change.
56
- producer :job_index do |_params|
51
+ # Cached: discovered job index. `watch:` (ADR-60 WD3) covers
52
+ # every `.rb` under `job_search_paths` so the cache invalidates
53
+ # when a job is added, removed, or edited; the discoverer's
54
+ # in-block `IoBoundary` reads are captured into the record-and-
55
+ # validate dependency descriptor after the block runs.
56
+ producer :job_index, watch: -> { [[@job_search_paths, "**/*.rb"]] } do |_params|
57
57
  JobDiscoverer.new(
58
58
  io_boundary: io_boundary,
59
59
  search_paths: @job_search_paths,
@@ -64,50 +64,33 @@ module Rigor
64
64
  def init(_services)
65
65
  @job_search_paths = Array(config.fetch("job_search_paths")).map(&:to_s)
66
66
  @job_base_classes = Array(config.fetch("job_base_classes")).map(&:to_s)
67
- @job_index = nil
68
- @load_error = nil
69
67
  end
70
68
 
71
69
  # File-level only: the load-error emission. Per-call arity
72
70
  # validation runs over the engine-owned walk via the node_rule
73
71
  # below (ADR-37). The job index is lazily loaded + memoised by
74
- # job_index_or_nil, shared by both surfaces.
72
+ # `producer_value`, shared by both surfaces.
75
73
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
76
- index = job_index_or_nil
77
- return [load_error_diagnostic(path)] if index.nil? && @load_error
74
+ index = producer_value(:job_index)
75
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:job_index)
78
76
 
79
77
  []
80
78
  end
81
79
 
82
80
  node_rule Prism::CallNode do |node, _scope, path|
83
- index = job_index_or_nil
81
+ index = producer_value(:job_index)
84
82
  next [] if index.nil? || index.empty?
85
83
 
86
- Analyzer.violations_for(call_node: node, job_index: index).map do |violation|
87
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
88
- end
84
+ diagnostics_for(Analyzer.violations_for(call_node: node, job_index: index), path: path, node: node)
89
85
  end
90
86
 
91
87
  private
92
88
 
93
- def job_index_or_nil
94
- return @job_index if @job_index
95
-
96
- # Read-then-cache pattern: the discoverer's
97
- # IoBoundary reads happen INSIDE `discover`, which is
98
- # invoked through `cache_for`'s producer block. The
99
- # boundary's accumulated FileEntry digests get
100
- # captured into the descriptor at cache_for time.
101
- @job_index = cache_for(:job_index, params: {}).call
102
- rescue StandardError => e
103
- @load_error = "rigor-activejob: failed to discover jobs: #{e.class}: #{e.message}"
104
- nil
105
- end
106
-
107
89
  def load_error_diagnostic(path)
90
+ error = producer_error(:job_index)
108
91
  Rigor::Analysis::Diagnostic.new(
109
92
  path: path, line: 1, column: 1,
110
- message: @load_error,
93
+ message: "rigor-activejob: failed to discover jobs: #{error.class}: #{error.message}",
111
94
  severity: :warning,
112
95
  rule: "load-error"
113
96
  )
@@ -227,8 +227,8 @@ module Rigor
227
227
  # Recognised single-instance and collection association
228
228
  # DSL methods. The kind drives the eventual return-type
229
229
  # contribution: singular associations narrow to
230
- # `Nominal[Target] | nil`, plural ones currently degrade
231
- # to the RBS envelope (relation types are a future track).
230
+ # `Nominal[Target] | nil`, plural ones narrow to
231
+ # `ActiveRecord::Relation[Target]`.
232
232
  #
233
233
  # `composed_of` value-object aggregations and
234
234
  # `delegated_type` roles are folded in here too — both
@@ -447,8 +447,8 @@ module Rigor
447
447
 
448
448
  # `scope :active, -> { ... }`. Records the scope name
449
449
  # only (the body is intentionally NOT introspected —
450
- # scopes return ActiveRecord::Relation, which Rigor
451
- # doesn't carry a precise type for yet).
450
+ # the caller contributes `ActiveRecord::Relation[Model]`
451
+ # based on the name alone via `class_scope_return_type`).
452
452
  def lookup_scopes(body)
453
453
  return [] if body.nil?
454
454