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
@@ -66,7 +66,7 @@ module Rigor
66
66
  }
67
67
  )
68
68
 
69
- producer :policy_index do |_params|
69
+ producer :policy_index, watch: -> { [[@policy_search_paths, "**/*.rb"]] } do |_params|
70
70
  PolicyDiscoverer.new(
71
71
  io_boundary: io_boundary,
72
72
  search_paths: @policy_search_paths,
@@ -77,46 +77,35 @@ module Rigor
77
77
  def init(_services)
78
78
  @policy_search_paths = Array(config.fetch("policy_search_paths")).map(&:to_s)
79
79
  @policy_base_classes = Array(config.fetch("policy_base_classes")).map(&:to_s)
80
- @policy_index = nil
81
- @load_error = nil
82
80
  end
83
81
 
84
82
  # File-level only: the load-error emission. The per-call policy
85
83
  # validation runs over the engine-owned walk via the node_rule
86
84
  # below (ADR-37). The index is lazily loaded + memoised by
87
- # policy_index_or_nil, so both surfaces share one load.
85
+ # `producer_value`, so both surfaces share one load.
88
86
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
89
- index = policy_index_or_nil
90
- return [load_error_diagnostic(path)] if index.nil? && @load_error
87
+ index = producer_value(:policy_index)
88
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:policy_index)
91
89
 
92
90
  []
93
91
  end
94
92
 
95
93
  node_rule Prism::CallNode do |node, scope, path|
96
- index = policy_index_or_nil
94
+ index = producer_value(:policy_index)
97
95
  next [] if index.nil? || index.empty?
98
96
 
99
- Analyzer.violations_for(call_node: node, policy_index: index, scope: scope).map do |violation|
100
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
101
- end
97
+ diagnostics_for(
98
+ Analyzer.violations_for(call_node: node, policy_index: index, scope: scope), path: path, node: node
99
+ )
102
100
  end
103
101
 
104
102
  private
105
103
 
106
- def policy_index_or_nil
107
- return @policy_index if @policy_index
108
-
109
- descriptor = glob_descriptor(@policy_search_paths, "**/*.rb")
110
- @policy_index = cache_for(:policy_index, params: {}, descriptor: descriptor).call
111
- rescue StandardError => e
112
- @load_error = "rigor-pundit: failed to discover policies: #{e.class}: #{e.message}"
113
- nil
114
- end
115
-
116
104
  def load_error_diagnostic(path)
105
+ error = producer_error(:policy_index)
117
106
  Rigor::Analysis::Diagnostic.new(
118
107
  path: path, line: 1, column: 1,
119
- message: @load_error,
108
+ message: "rigor-pundit: failed to discover policies: #{error.class}: #{error.message}",
120
109
  severity: :warning,
121
110
  rule: "load-error"
122
111
  )
@@ -169,7 +169,7 @@ module Rigor
169
169
 
170
170
  # Extracts the literal-string first argument when
171
171
  # present. Returns nil for variable / expression keys —
172
- # those are out of scope for v0.1.0.
172
+ # only literal keys are statically validated.
173
173
  def literal_key_for(call_node)
174
174
  args = call_node.arguments&.arguments || []
175
175
  return nil if args.empty?
@@ -224,9 +224,7 @@ module Rigor
224
224
 
225
225
  def collect_assoc_keys(hash_node)
226
226
  # Both `Prism::HashNode` and `Prism::KeywordHashNode`
227
- # expose `#elements`; the conditional was an
228
- # accidental no-op carried over from an earlier
229
- # draft.
227
+ # expose `#elements`, so a single path handles both.
230
228
  hash_node.elements.filter_map do |element|
231
229
  next nil unless element.is_a?(Prism::AssocNode)
232
230
 
@@ -57,7 +57,7 @@ module Rigor
57
57
  parsed.each do |locale, tree|
58
58
  locale = locale.to_s
59
59
  locales << locale
60
- flatten_tree(tree, []).each do |dotted_key, value|
60
+ each_flattened(tree, []) do |dotted_key, value|
61
61
  placeholders = (per_key[dotted_key] ||= {})
62
62
  placeholders[locale] = extract_placeholders(value)
63
63
  kinds = (per_key_kinds[dotted_key] ||= {})
@@ -101,24 +101,40 @@ module Rigor
101
101
  end
102
102
 
103
103
  # Recursively walks the per-locale subtree, yielding
104
- # `[dotted_key, leaf_value]` pairs. Hash leaves are
104
+ # `[dotted_key, leaf_value]` for each leaf. Hash leaves are
105
105
  # *not* recorded as entries themselves — only their
106
- # descendants — but every leaf scalar / array IS
107
- # recorded.
108
- def flatten_tree(node, breadcrumbs)
109
- case node
110
- when Hash
111
- node.flat_map do |k, v|
112
- flatten_tree(v, breadcrumbs + [k.to_s])
106
+ # descendants — but every leaf scalar / array IS recorded.
107
+ #
108
+ # `breadcrumbs` is a single mutable stack reused across the
109
+ # whole walk (push before recursing, pop after): for the
110
+ # 530-file / 14 MB Mastodon locale corpus the old
111
+ # `flat_map { flatten_tree(v, breadcrumbs + [k]) }` shape
112
+ # allocated a fresh breadcrumb Array at every node plus an
113
+ # intermediate result Array at every level — millions of
114
+ # short-lived objects and the run's top allocation site. The
115
+ # dotted key is still materialised once per leaf (it has to
116
+ # be); everything else is now allocation-free traversal.
117
+ def each_flattened(node, breadcrumbs, &)
118
+ if node.is_a?(Hash)
119
+ node.each do |k, v|
120
+ breadcrumbs.push(k.to_s)
121
+ each_flattened(v, breadcrumbs, &)
122
+ breadcrumbs.pop
113
123
  end
114
124
  else
115
- [[breadcrumbs.join("."), node]]
125
+ yield breadcrumbs.join("."), node
116
126
  end
117
127
  end
118
128
 
119
129
  def extract_placeholders(value)
120
130
  case value
121
- when String then value.scan(PLACEHOLDER_RE).flatten.to_set
131
+ when String
132
+ # Most locale leaves carry no `%{var}`; skip the scan +
133
+ # flatten + to_set allocation trio for them. A string with
134
+ # no `%{` yields an empty placeholder set either way.
135
+ return Set.new unless value.include?("%{")
136
+
137
+ value.scan(PLACEHOLDER_RE).flatten.to_set
122
138
  when Array then value.map { |v| extract_placeholders(v) }.reduce(Set.new) { |a, s| a | s }
123
139
  else Set.new
124
140
  end
@@ -40,7 +40,7 @@ module Rigor
40
40
  # arguments. Missing placeholders are errors; extra
41
41
  # arguments are warnings.
42
42
  #
43
- # ## Limitations (v0.1.0)
43
+ # ## Limitations
44
44
  #
45
45
  # - Only literal-string keys are validated. `t(key)` with
46
46
  # a variable receiver is silently passed through.
@@ -72,7 +72,13 @@ module Rigor
72
72
  }
73
73
  )
74
74
 
75
- producer :locale_index do |_params|
75
+ # `watch:` covers every `.yml` / `.yaml` file under the locale
76
+ # search paths so the cache invalidates when locale files are
77
+ # added, removed, or edited (ADR-60 WD3). `@load_errors` is a
78
+ # producer-side capture: it is populated only when the block
79
+ # runs (a cache miss / a watched file changed), which is exactly
80
+ # when a malformed YAML must re-surface.
81
+ producer :locale_index, watch: -> { [[@locale_search_paths, "**/*.yml", "**/*.yaml"]] } do |_params|
76
82
  loader = LocaleLoader.new(
77
83
  io_boundary: io_boundary,
78
84
  search_paths: @locale_search_paths
@@ -85,21 +91,19 @@ module Rigor
85
91
  def init(_services)
86
92
  @locale_search_paths = Array(config.fetch("locale_search_paths")).map(&:to_s)
87
93
  @configured_locales = Array(config.fetch("configured_locales")).map(&:to_s)
88
- @locale_index = nil
89
94
  @load_errors = []
90
95
  @load_errors_emitted = false
91
- @runtime_error = nil
92
96
  end
93
97
 
94
98
  # File-level only: the once-per-run YAML load errors + the
95
99
  # runtime (cache-load) error. Per-call `t('key')` validation runs
96
100
  # over the engine-owned walk via the node_rule below (ADR-37). The
97
- # locale index is lazily loaded + memoised by locale_index_or_nil.
101
+ # locale index is lazily loaded + memoised by `producer_value`.
98
102
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
99
- index = locale_index_or_nil
103
+ index = producer_value(:locale_index)
100
104
  diagnostics = []
101
105
  diagnostics.concat(consume_load_error_diagnostics(path)) unless @load_errors.empty?
102
- diagnostics << runtime_error_diagnostic(path) if index.nil? && @runtime_error
106
+ diagnostics << runtime_error_diagnostic(path) if index.nil? && producer_error(:locale_index)
103
107
  diagnostics
104
108
  end
105
109
 
@@ -107,39 +111,21 @@ module Rigor
107
111
  # (the controller action), supplied by the node-rule NodeContext;
108
112
  # the controller scope comes from the file path.
109
113
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
110
- index = locale_index_or_nil
114
+ index = producer_value(:locale_index)
111
115
  next [] if index.nil? || index.empty?
112
116
 
113
- Analyzer.violations_for(
114
- call_node: node, locale_index: index, configured_locales: @configured_locales,
115
- controller_scope: Analyzer.controller_scope_from_path(path),
116
- action: context.enclosing_def&.name
117
- ).map do |violation|
118
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
119
- end
117
+ diagnostics_for(
118
+ Analyzer.violations_for(
119
+ call_node: node, locale_index: index, configured_locales: @configured_locales,
120
+ controller_scope: Analyzer.controller_scope_from_path(path),
121
+ action: context.enclosing_def&.name
122
+ ),
123
+ path: path, node: node
124
+ )
120
125
  end
121
126
 
122
127
  private
123
128
 
124
- def locale_index_or_nil
125
- return @locale_index if @locale_index
126
-
127
- # Pass an explicit descriptor covering every `.yml` / `.yaml`
128
- # file under the configured locale search paths so the cache
129
- # invalidates when locale files are added, removed, or edited.
130
- # Without it the auto-built descriptor depends on the
131
- # `IoBoundary`'s in-process read history — empty on the
132
- # first call of a fresh process — so warm cache hits would
133
- # serve stale `LocaleIndex` data and hide per-call load
134
- # errors (a malformed YAML in one run would not surface
135
- # when a healthy cache entry from an earlier run exists).
136
- descriptor = glob_descriptor(@locale_search_paths, "**/*.yml", "**/*.yaml")
137
- @locale_index = cache_for(:locale_index, params: {}, descriptor: descriptor).call
138
- rescue StandardError => e
139
- @runtime_error = "rigor-rails-i18n: failed to load locales: #{e.class}: #{e.message}"
140
- nil
141
- end
142
-
143
129
  # The runner only invokes `diagnostics_for_file` for
144
130
  # Ruby files (`paths:` is filtered to `.rb`). YAML
145
131
  # parse errors therefore can't be anchored on the
@@ -161,9 +147,10 @@ module Rigor
161
147
  end
162
148
 
163
149
  def runtime_error_diagnostic(path)
150
+ error = producer_error(:locale_index)
164
151
  Rigor::Analysis::Diagnostic.new(
165
152
  path: path, line: 1, column: 1,
166
- message: @runtime_error,
153
+ message: "rigor-rails-i18n: failed to load locales: #{error.class}: #{error.message}",
167
154
  severity: :warning,
168
155
  rule: "load-error"
169
156
  )
@@ -38,11 +38,9 @@ module Rigor
38
38
  # (`user_facebook_omniauth_authorize_path`) and Devise
39
39
  # declares them from the configured providers, which
40
40
  # live in an initializer this static parser does not
41
- # read. We register the SHAPE-FAMILY suffix matchers
42
- # `_omniauth_authorize_path` / `_omniauth_callback_path`
43
- # via a separate hook (see `OMNIAUTH_HELPER_PATTERNS`);
44
- # these are NOT in the table but consulted by the
45
- # `Analyzer.allowed_dynamic_pattern?` check.
41
+ # read. The suffix patterns are registered in
42
+ # `OMNIAUTH_SUFFIXES` and consulted by
43
+ # `HelperTable#omniauth_match?`.
46
44
  module DeviseRoutes
47
45
  # The standard Devise controllers and the helper
48
46
  # actions each generates. Keys are the controller
@@ -190,7 +188,7 @@ module Rigor
190
188
 
191
189
  # Returns the set of OmniAuth pattern suffixes the
192
190
  # analyzer accepts for a given resource. The analyzer
193
- # consults this set (via `HelperTable#allows_omniauth?`)
191
+ # consults this set (via `HelperTable#omniauth_match?`)
194
192
  # when a `*_path` / `*_url` call's name does not match
195
193
  # any registered entry and its prefix matches a Devise
196
194
  # resource.
@@ -1171,9 +1171,10 @@ module Rigor
1171
1171
  end
1172
1172
 
1173
1173
  def in_singular_resource?(*)
1174
- # Slice 1 doesn't model the singular-resource frame
1175
- # separately; placeholder so member / collection
1176
- # blocks at least descend.
1174
+ # Stub: always returns true so member / collection
1175
+ # blocks descend. The singular-resource frame
1176
+ # (`push_singular_resource`) is modelled in Context;
1177
+ # a future caller could use it to tighten this check.
1177
1178
  true
1178
1179
  end
1179
1180
 
@@ -1181,21 +1182,14 @@ module Rigor
1181
1182
  # `plural: true` for `resources :users`, `false` for
1182
1183
  # `resource :profile`.
1183
1184
  def register_resourceful_helpers(name, actions, base_arity, context, plural:)
1184
- # Singular resources (`resource :foo`) use the
1185
- # given name AS-IS for both path and helper
1186
- # singularising would mangle a deliberately-plural
1187
- # singular-DSL name like Mastodon's
1188
- # `resource :relationships, only: [:show, :update]`
1189
- # (Rails generates `relationships_path`, not
1190
- # `relationship_path`). Plural resources still
1191
- # singularise for the show / new / edit helpers.
1192
- # Plural resources singularise for show / new / edit
1193
- # helpers (`resources :users` → `user_path(id)`);
1194
- # singular resources use the name AS-IS even when it
1195
- # looks plural (Mastodon's `resource :relationships,
1196
- # only: [:show, :update]` → `relationships_path`).
1197
- # The path segment uses `name` in both shapes — Rails
1198
- # never singularises the URL.
1185
+ # Plural resources (`resources :users`) singularise
1186
+ # for show / new / edit helpers `user_path(id)`.
1187
+ # Singular resources (`resource :foo`) use the name
1188
+ # AS-IS singularising would mangle deliberately-
1189
+ # plural names like Mastodon's `resource
1190
+ # :relationships` `relationships_path`, not
1191
+ # `relationship_path`. The URL path uses `name`
1192
+ # in both shapes (Rails never singularises the URL).
1199
1193
  singular = plural ? singularize_word(name.to_s) : name.to_s
1200
1194
  path_base = "#{context.path_prefix}/#{name}"
1201
1195
 
@@ -26,9 +26,9 @@ module Rigor
26
26
  # - One level of nested `resources`
27
27
  #
28
28
  # The plugin publishes its parsed `:helper_table` through
29
- # the ADR-9 cross-plugin fact store so future
30
- # `rigor-actionpack` Phase 4 can consume it for
31
- # route-helper validation in controller code.
29
+ # the ADR-9 cross-plugin fact store; `rigor-actionpack`
30
+ # Phase 4 consumes it for route-helper validation in
31
+ # controller code.
32
32
  #
33
33
  # ## Configuration
34
34
  #
@@ -94,7 +94,12 @@ module Rigor
94
94
  #
95
95
  # Passes a `file_reader` lambda so the parser can follow
96
96
  # `draw(:admin)` → `config/routes/admin.rb` partials.
97
- producer :helper_table do |_params|
97
+ # `watch:` (ADR-60 WD3) covers every `.rb` under `helper_paths:`
98
+ # so ADDING a helper file invalidates the table — the producer's
99
+ # own in-block reads (the routes file + the partials it follows
100
+ # via `draw`) are captured post-compute, but a brand-new helper
101
+ # file the prior run never read would otherwise read as fresh.
102
+ producer :helper_table, watch: -> { @helper_paths.map { |dir| [dir, "**/*.rb"] } } do |_params|
98
103
  routes_dir = "#{File.dirname(@routes_file)}/routes"
99
104
  file_reader = lambda do |name|
100
105
  io_boundary.read_file("#{routes_dir}/#{name}")
@@ -132,14 +137,6 @@ module Rigor
132
137
  HelperDiscoverer.discover(contents_per_path)
133
138
  end
134
139
 
135
- def pre_read_helper_files
136
- each_helper_file do |path|
137
- io_boundary.read_file(path)
138
- rescue Plugin::AccessDeniedError, Errno::ENOENT
139
- next
140
- end
141
- end
142
-
143
140
  def each_helper_file(&)
144
141
  @helper_paths.each do |dir|
145
142
  absolute = File.expand_path(dir)
@@ -150,8 +147,8 @@ module Rigor
150
147
  end
151
148
 
152
149
  # Publishes the parsed table to the cross-plugin fact
153
- # store so future Tier 2 plugins (rigor-actionpack
154
- # Phase 4) can read it via `services.fact_store.read`.
150
+ # store; `rigor-actionpack` Phase 4 reads it via
151
+ # `services.fact_store.read`.
155
152
  def prepare(services)
156
153
  table = helper_table_or_nil
157
154
  return if table.nil?
@@ -214,15 +211,11 @@ module Rigor
214
211
  return @helper_table if @helper_table_built
215
212
 
216
213
  @helper_table_built = true
217
- # Read first so the IoBoundary's FileEntry digest
218
- # captures into the descriptor before `cache_for`
219
- # snapshots it (the same pattern documented in
220
- # rigor-routes / rigor-activerecord). Helper files are
221
- # pre-read for the same reason — editing a file under
222
- # `app/helpers/` MUST invalidate the helper_table cache
223
- # so the new custom-helper set is picked up.
224
- io_boundary.read_file(@routes_file)
225
- pre_read_helper_files
214
+ # ADR-60 WD3 record-and-validate: the producer's in-block reads
215
+ # (the routes file + the partials it follows) are captured into
216
+ # the dependency descriptor AFTER the block runs, and the
217
+ # producer's `watch:` covers helper-file additions so no
218
+ # priming read is needed here.
226
219
  @helper_table = cache_for(:helper_table, params: {}).call
227
220
  rescue Plugin::AccessDeniedError => e
228
221
  @load_error = "rigor-rails-routes: #{e.message}"
@@ -147,7 +147,6 @@ module Rigor
147
147
  block_as_methods: base.block_as_methods,
148
148
  heredoc_templates: base.heredoc_templates,
149
149
  trait_registries: base.trait_registries,
150
- external_files: base.external_files,
151
150
  hkt_registrations: base.hkt_registrations,
152
151
  hkt_definitions: base.hkt_definitions,
153
152
  signature_paths: base.signature_paths,
@@ -21,10 +21,9 @@ module Rigor
21
21
  # { ... }` and `subject(:name) { ... }` declarations.
22
22
  # `:subject` is the key for the implicit `subject { ... }`.
23
23
  #
24
- # Pillar 2 Slice 2 — used by the plugin's let-binding
25
- # `dynamic_return` rule to bind `let`-named
26
- # method-shape calls inside `it` bodies to the let
27
- # block's inferred type.
24
+ # Used by the plugin's let-binding `dynamic_return`
25
+ # rule to bind `let`-named method-shape calls inside
26
+ # `it` bodies to the let block's inferred type.
28
27
  class LetScopeIndex
29
28
  Record = Struct.new(:outer_range, :describe_const, :lets, keyword_init: true) do
30
29
  def contains?(line) = outer_range.cover?(line)
@@ -6,7 +6,7 @@ require "rigor/type"
6
6
  module Rigor
7
7
  module Plugin
8
8
  class Rspec < Rigor::Plugin::Base
9
- # Pillar 2 Slice 2 — resolves the runtime type of a
9
+ # Resolves the runtime type of a
10
10
  # `let(:name) { body }` or `subject(:name) { body }`
11
11
  # block by pattern-matching its body's tail expression.
12
12
  #
@@ -8,7 +8,7 @@ require "rigor/flow_contribution/fact"
8
8
  module Rigor
9
9
  module Plugin
10
10
  class Rspec < Rigor::Plugin::Base
11
- # Pillar 2 Slice 1 — recognises `expect(x).to MATCHER`
11
+ # Recognises `expect(x).to MATCHER`
12
12
  # patterns at per-call recognition time and emits
13
13
  # `post_return_facts` that narrow the named local on the
14
14
  # post-call edge.
@@ -11,15 +11,13 @@ require_relative "rspec/let_type_resolver"
11
11
  module Rigor
12
12
  module Plugin
13
13
  # rigor-rspec — validates RSpec `let` / `subject`
14
- # declarations within each describe / context scope.
14
+ # declarations within each describe / context scope,
15
+ # narrows `expect(x).to MATCHER` assertions downstream,
16
+ # and binds `let`/`subject` locals to their inferred
17
+ # return types inside `it` bodies.
15
18
  #
16
19
  # Tier 3A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
17
- # Deliberately scoped — the roadmap describes a much
18
- # larger plugin (let-typo detection in `it` bodies,
19
- # `expect(x).to receive(:method)` mock-target
20
- # validation). Both are out of scope for v0.1.0; this
21
- # plugin ships the two checks that have the lowest
22
- # false-positive risk:
20
+ # Ships four checks:
23
21
  #
24
22
  # 1. **Duplicate `let` / `subject` declarations** in
25
23
  # the same scope (`warning`). RSpec's runtime lets
@@ -31,14 +29,21 @@ module Rigor
31
29
  # (`error`). At runtime this infinite-loops; users
32
30
  # typically meant to call a different method or
33
31
  # forgot to introduce a `super`.
32
+ # 3. **`expect(x).to MATCHER` narrowing** — narrows
33
+ # the named local `x` on the post-call edge for
34
+ # eight matchers (see `MatcherAnalyzer`).
35
+ # 4. **`let`/`subject` local binding** — binds `let`
36
+ # / `subject` names in `it` bodies to the block's
37
+ # inferred return type (see `LetTypeResolver`).
34
38
  #
35
39
  # ## Configuration
36
40
  #
37
- # No knobs in v0.1.0. The plugin walks every analysed
38
- # file looking for `RSpec.describe ... do` blocks; spec
39
- # files outside the project's `paths:` are not scanned.
41
+ # No configuration knobs. The plugin walks every
42
+ # analysed file for `RSpec.describe ... do` blocks;
43
+ # spec files outside the project's `paths:` are not
44
+ # scanned.
40
45
  #
41
- # ## Limitations (v0.1.0)
46
+ # ## Limitations
42
47
  #
43
48
  # - **No let-typo detection.** Detecting an `it`
44
49
  # block's reference to a misspelled `let` name
@@ -86,10 +91,7 @@ module Rigor
86
91
  ]
87
92
  )
88
93
 
89
- def init(services)
90
- @services = services
91
- @factory_index_resolved = false
92
- @factory_index = nil
94
+ def init(_services)
93
95
  # Per-path `LetScopeIndex` cache. The let-binding
94
96
  # `dynamic_return` rule (and its `file_methods:` gate)
95
97
  # consult the index per call node; building it once
@@ -105,14 +107,14 @@ module Rigor
105
107
  Analyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
106
108
  end
107
109
 
108
- # ADR-37 slice 2 — Pillar 2 Slice 1 matcher narrowing
110
+ # ADR-37 slice 2 — matcher narrowing
109
111
  # (`expect(x).to be_a(T)` → `post_return_facts` on `x`),
110
112
  # method-gated by the engine on the expectation verbs.
111
113
  type_specifier methods: %i[to not_to to_not] do |call_node, scope|
112
114
  MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
113
115
  end
114
116
 
115
- # Pillar 2 Slice 2 / ADR-52 slice 5a — binds local reads in `it` /
117
+ # ADR-52 slice 5a — binds local reads in `it` /
116
118
  # spec bodies to their `let(:name) { ... }` block's inferred return
117
119
  # type. The name set varies per file (each spec file's
118
120
  # `describe`/`let` structure), so the rule gates on the per-file
@@ -133,7 +135,7 @@ module Rigor
133
135
  let_scope_index_for(path)&.let_names || []
134
136
  end
135
137
 
136
- # Pillar 2 Slice 2 — when the call node is a no-receiver
138
+ # When the call node is a no-receiver
137
139
  # method call (`user`, `subject`, etc.) inside an RSpec
138
140
  # `describe` block whose lets include a matching name,
139
141
  # return the let block's inferred type.
@@ -152,7 +154,7 @@ module Rigor
152
154
  LetTypeResolver.resolve(
153
155
  block_node,
154
156
  describe_const: describe_const,
155
- factory_index: factory_index_or_nil,
157
+ factory_index: read_fact(plugin_id: "factorybot", name: :factory_index),
156
158
  environment: scope.environment
157
159
  )
158
160
  end
@@ -185,14 +187,6 @@ module Rigor
185
187
  nil
186
188
  end
187
189
 
188
- def factory_index_or_nil
189
- return @factory_index if @factory_index_resolved
190
-
191
- @factory_index = @services&.fact_store&.read(plugin_id: "factorybot", name: :factory_index)
192
- @factory_index_resolved = true
193
- @factory_index
194
- end
195
-
196
190
  def build_diagnostic(diag)
197
191
  Rigor::Analysis::Diagnostic.new(
198
192
  path: diag.path, line: diag.line, column: diag.column,
@@ -31,7 +31,6 @@ module Rigor
31
31
  # validate_absence_of(:col)
32
32
  # validate_format_of(:col)
33
33
  # validate_confirmation_of(:col)
34
- # allow_value(...).for(:col)
35
34
  # have_db_column(:col)
36
35
  # have_db_index(:col)
37
36
  #
@@ -70,19 +70,14 @@ module Rigor
70
70
  ]
71
71
  )
72
72
 
73
- def init(services)
74
- @services = services
75
- @model_index = nil
76
- @model_index_resolved = false
77
- end
78
-
79
73
  # ADR-37 — per-matcher validation over the engine-owned walk. The
80
74
  # model anchor (the enclosing `describe <Model>` const) comes from
81
75
  # the node-rule NodeContext ancestors; the diagnostic points at the
82
76
  # matcher name (message_loc). The :model_index fact (from
83
- # rigor-activerecord) is read lazily; without it the rule is silent.
77
+ # rigor-activerecord) is read lazily via `read_fact`; without it
78
+ # the rule is silent.
84
79
  node_rule Prism::CallNode do |node, _scope, path, _fc, context|
85
- index = model_index_or_nil
80
+ index = read_fact(plugin_id: "activerecord", name: :model_index)
86
81
  next [] if index.nil?
87
82
 
88
83
  Analyzer.violations_for(matcher_call: node, ancestors: context.ancestors, model_index: index).map do |violation|
@@ -90,21 +85,6 @@ module Rigor
90
85
  message: violation.message, severity: :warning, rule: violation.rule)
91
86
  end
92
87
  end
93
-
94
- private
95
-
96
- # Lazily resolves `:model_index` from
97
- # `rigor-activerecord`. Returns nil when the plugin
98
- # isn't loaded or no index has been published; the
99
- # analyzer treats nil as "no cross-check available" and
100
- # falls silent.
101
- def model_index_or_nil
102
- return @model_index if @model_index_resolved
103
-
104
- @model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
105
- @model_index_resolved = true
106
- @model_index
107
- end
108
88
  end
109
89
 
110
90
  Rigor::Plugin.register(ShouldaMatchers)
@@ -9,10 +9,11 @@ module Rigor
9
9
  # analyzer can validate `Worker.perform_async(...)`
10
10
  # call sites.
11
11
  #
12
- # Same envelope shape as `rigor-activejob`'s
13
- # `JobIndex::Entry`: `min_arity` / `max_arity` form a
14
- # closed range (`Float::INFINITY` for the upper bound
15
- # when `*args` is present).
12
+ # Uses the same `min_arity` / `max_arity` closed-range
13
+ # envelope as `rigor-activejob`'s `JobIndex::Entry`
14
+ # (`Float::INFINITY` upper bound when `*args` is present);
15
+ # Sidekiq workers serialize args to JSON so keyword arity
16
+ # is not tracked here.
16
17
  class WorkerIndex
17
18
  Entry = Data.define(:class_name, :min_arity, :max_arity) do
18
19
  def arity_label