rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -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
  )
@@ -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)
@@ -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,8 +21,8 @@ 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
25
- # `flow_contribution_for` hook to bind `let`-named
24
+ # Pillar 2 Slice 2 — used by the plugin's let-binding
25
+ # `dynamic_return` rule to bind `let`-named
26
26
  # method-shape calls inside `it` bodies to the let
27
27
  # block's inferred type.
28
28
  class LetScopeIndex
@@ -48,6 +48,16 @@ module Rigor
48
48
  @records.select { |rec| rec.contains?(line) }
49
49
  end
50
50
 
51
+ # ADR-52 slice 5a — every `let` / `subject` name declared
52
+ # anywhere in the file, across all describe scopes. Feeds the
53
+ # plugin's `dynamic_return file_methods:` gate: the engine only
54
+ # consults the rule for a call whose name appears here; the
55
+ # precise line-scoped resolution stays in `let_block_at`.
56
+ # @return [Array<Symbol>]
57
+ def let_names
58
+ @records.flat_map { |rec| rec.lets.keys }.uniq
59
+ end
60
+
51
61
  # Resolves a `let` name at the given line by walking
52
62
  # records innermost to outermost.
53
63
  # @return [Prism::BlockNode, nil]
@@ -9,7 +9,7 @@ module Rigor
9
9
  module Plugin
10
10
  class Rspec < Rigor::Plugin::Base
11
11
  # Pillar 2 Slice 1 — recognises `expect(x).to MATCHER`
12
- # patterns at `flow_contribution_for` time and emits
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.
15
15
  #
@@ -72,23 +72,31 @@ module Rigor
72
72
  "`subject { described_class.new(...) }`.",
73
73
  consumes: [
74
74
  { plugin_id: "factorybot", name: :factory_index, optional: true }
75
+ ],
76
+ additional_initializers: [
77
+ # ADR-38 block-form: `before { @ivar = … }`, `let(:x) { @ivar = … }`,
78
+ # and `subject { @ivar = … }` establish ivar state before `it` bodies
79
+ # run. Declaring them here suppresses the read-before-write nil
80
+ # widening that would otherwise appear on those ivars in `it` / `specify`
81
+ # sibling blocks.
82
+ Rigor::Plugin::AdditionalInitializer.new(
83
+ receiver_constraint: "RSpec::ExampleGroup",
84
+ block_methods: %i[before let subject]
85
+ )
75
86
  ]
76
87
  )
77
88
 
78
- def init(services)
79
- @services = services
80
- @factory_index_resolved = false
81
- @factory_index = nil
82
- # Per-path `LetScopeIndex` cache. The plugin's
83
- # `flow_contribution_for` is called for every call
84
- # node the dispatcher visits; building the index once
89
+ def init(_services)
90
+ # Per-path `LetScopeIndex` cache. The let-binding
91
+ # `dynamic_return` rule (and its `file_methods:` gate)
92
+ # consult the index per call node; building it once
85
93
  # per file is essential for performance.
86
94
  @let_index_cache = {}
87
95
  end
88
96
 
89
97
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
90
98
  # Build the let-scope index for this file while we
91
- # have the parsed root in hand — `flow_contribution_for`
99
+ # have the parsed root in hand — the let-binding rule
92
100
  # picks it up from `@let_index_cache` keyed on path.
93
101
  @let_index_cache[path] ||= LetScopeIndex.build(root)
94
102
  Analyzer.diagnose(path: path, root: root).map { |diag| build_diagnostic(diag) }
@@ -101,23 +109,32 @@ module Rigor
101
109
  MatcherAnalyzer.contribution_for(call_node, environment: scope&.environment)&.post_return_facts
102
110
  end
103
111
 
104
- # Pillar 2 Slice 2 — binds local reads in `it` / spec bodies to
105
- # their `let(:name) { ... }` block's inferred return type. This is
106
- # a *method-gated return type* (keyed on the let-bound name read),
107
- # which the receiver-gated `dynamic_return` cannot express, so it
108
- # stays on `flow_contribution_for` the deprecated escape valve
109
- # (ADR-37 slice 2 § "Outcome").
110
- def flow_contribution_for(call_node:, scope:)
111
- let_binding_contribution(call_node, scope)
112
+ # Pillar 2 Slice 2 / ADR-52 slice 5a — binds local reads in `it` /
113
+ # spec bodies to their `let(:name) { ... }` block's inferred return
114
+ # type. The name set varies per file (each spec file's
115
+ # `describe`/`let` structure), so the rule gates on the per-file
116
+ # `file_methods:` form: the engine resolves the file's let names
117
+ # once per analysed file and consults the block only for a listed
118
+ # name; the line-scoped shadowing resolution stays in the block.
119
+ dynamic_return file_methods: ->(path) { let_names_for(path) } do |call_node, scope|
120
+ let_binding_return_type(call_node, scope)
112
121
  end
113
122
 
114
123
  private
115
124
 
125
+ # The `file_methods:` gate set — every `let` / `subject` name the
126
+ # file declares anywhere. A safe over-approximation of the block's
127
+ # own `let_block_at` line-scoped lookup (a name read outside its
128
+ # describe scope passes the gate and is declined by the block).
129
+ def let_names_for(path)
130
+ let_scope_index_for(path)&.let_names || []
131
+ end
132
+
116
133
  # Pillar 2 Slice 2 — when the call node is a no-receiver
117
134
  # method call (`user`, `subject`, etc.) inside an RSpec
118
135
  # `describe` block whose lets include a matching name,
119
- # return a `FlowContribution(return_type: <inferred>)`.
120
- def let_binding_contribution(call_node, scope)
136
+ # return the let block's inferred type.
137
+ def let_binding_return_type(call_node, scope)
121
138
  return nil if scope.nil?
122
139
  return nil unless candidate_call?(call_node)
123
140
 
@@ -129,15 +146,12 @@ module Rigor
129
146
  return nil if block_node.nil?
130
147
 
131
148
  describe_const = index.describe_const_at(line)
132
- type = LetTypeResolver.resolve(
149
+ LetTypeResolver.resolve(
133
150
  block_node,
134
151
  describe_const: describe_const,
135
- factory_index: factory_index_or_nil,
152
+ factory_index: read_fact(plugin_id: "factorybot", name: :factory_index),
136
153
  environment: scope.environment
137
154
  )
138
- return nil if type.nil?
139
-
140
- Rigor::FlowContribution.new(return_type: type)
141
155
  end
142
156
 
143
157
  def candidate_call?(call_node)
@@ -168,14 +182,6 @@ module Rigor
168
182
  nil
169
183
  end
170
184
 
171
- def factory_index_or_nil
172
- return @factory_index if @factory_index_resolved
173
-
174
- @factory_index = @services&.fact_store&.read(plugin_id: "factorybot", name: :factory_index)
175
- @factory_index_resolved = true
176
- @factory_index
177
- end
178
-
179
185
  def build_diagnostic(diag)
180
186
  Rigor::Analysis::Diagnostic.new(
181
187
  path: diag.path, line: diag.line, column: diag.column,
@@ -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)
@@ -63,7 +63,7 @@ module Rigor
63
63
  }
64
64
  )
65
65
 
66
- producer :worker_index do |_params|
66
+ producer :worker_index, watch: -> { [[@worker_search_paths, "**/*.rb"]] } do |_params|
67
67
  WorkerDiscoverer.new(
68
68
  io_boundary: io_boundary,
69
69
  search_paths: @worker_search_paths,
@@ -74,46 +74,33 @@ module Rigor
74
74
  def init(_services)
75
75
  @worker_search_paths = Array(config.fetch("worker_search_paths")).map(&:to_s)
76
76
  @worker_marker_modules = Array(config.fetch("worker_marker_modules")).map(&:to_s)
77
- @worker_index = nil
78
- @load_error = nil
79
77
  end
80
78
 
81
79
  # File-level only: the load-error emission. The per-call arity
82
80
  # validation runs over the engine-owned walk via the node_rule
83
81
  # below (ADR-37). The worker index is lazily loaded + memoised by
84
- # worker_index_or_nil, shared by both surfaces.
82
+ # `producer_value`, shared by both surfaces.
85
83
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
86
- index = worker_index_or_nil
87
- return [load_error_diagnostic(path)] if index.nil? && @load_error
84
+ index = producer_value(:worker_index)
85
+ return [load_error_diagnostic(path)] if index.nil? && producer_error(:worker_index)
88
86
 
89
87
  []
90
88
  end
91
89
 
92
90
  node_rule Prism::CallNode do |node, _scope, path|
93
- index = worker_index_or_nil
91
+ index = producer_value(:worker_index)
94
92
  next [] if index.nil? || index.empty?
95
93
 
96
- Analyzer.violations_for(call_node: node, worker_index: index).map do |violation|
97
- diagnostic(node, path: path, message: violation.message, severity: violation.severity, rule: violation.rule)
98
- end
94
+ diagnostics_for(Analyzer.violations_for(call_node: node, worker_index: index), path: path, node: node)
99
95
  end
100
96
 
101
97
  private
102
98
 
103
- def worker_index_or_nil
104
- return @worker_index if @worker_index
105
-
106
- descriptor = glob_descriptor(@worker_search_paths, "**/*.rb")
107
- @worker_index = cache_for(:worker_index, params: {}, descriptor: descriptor).call
108
- rescue StandardError => e
109
- @load_error = "rigor-sidekiq: failed to discover workers: #{e.class}: #{e.message}"
110
- nil
111
- end
112
-
113
99
  def load_error_diagnostic(path)
100
+ error = producer_error(:worker_index)
114
101
  Rigor::Analysis::Diagnostic.new(
115
102
  path: path, line: 1, column: 1,
116
- message: @load_error,
103
+ message: "rigor-sidekiq: failed to discover workers: #{error.class}: #{error.message}",
117
104
  severity: :warning,
118
105
  rule: "load-error"
119
106
  )
@@ -74,7 +74,7 @@ module Rigor
74
74
  block_as_methods: [
75
75
  Rigor::Plugin::Macro::BlockAsMethod.new(
76
76
  receiver_constraint: "Sinatra::Base",
77
- verbs: %i[get post put delete head options patch link unlink]
77
+ method_names: %i[get post put delete head options patch link unlink]
78
78
  )
79
79
  ]
80
80
  )
@@ -28,16 +28,15 @@ module Rigor
28
28
  #
29
29
  # ## Two-phase mechanism
30
30
  #
31
- # The recogniser is invoked from `flow_contribution_for`
31
+ # The recogniser is invoked from the plugin's `dynamic_return` rule
32
32
  # where the per-node `scope:` carries the proper narrowing
33
- # context. It returns:
33
+ # context. The rule:
34
34
  #
35
- # - A `FlowContribution` with `return_type: bot` and
36
- # `exceptional: :raises` regardless of reachability
37
- # (faithful to `T.absurd`'s runtime behaviour: it always
38
- # raises). This lets the engine's existing flow analysis
39
- # treat code after `T.absurd` as unreachable, matching
40
- # what users of Sorbet expect.
35
+ # - Contributes a `bot` return type regardless of
36
+ # reachability (faithful to `T.absurd`'s runtime
37
+ # behaviour: it always raises). This lets the engine's
38
+ # existing flow analysis treat code after `T.absurd` as
39
+ # unreachable, matching what users of Sorbet expect.
41
40
  # - When the branch is REACHABLE (the discriminant's type
42
41
  # isn't `bot`), the recogniser also records the call
43
42
  # node in a per-plugin set. The plugin's
@@ -47,7 +46,7 @@ module Rigor
47
46
  # call_node whose object identity matches the recorded
48
47
  # set. We rely on the runner only parsing each file
49
48
  # once per run, so the same Prism node object is seen
50
- # in both `flow_contribution_for` and
49
+ # in both the `dynamic_return` rule and
51
50
  # `diagnostics_for_file`.
52
51
  module AbsurdRecognizer
53
52
  # @param call_node [Prism::CallNode]
@@ -82,26 +81,6 @@ module Rigor
82
81
  # diagnostic fires conservatively.
83
82
  false
84
83
  end
85
-
86
- # The contribution every `T.absurd` call gets,
87
- # regardless of static reachability — `T.absurd` raises
88
- # at runtime, so its return type is `bot` and the call
89
- # is exceptional. This lets the engine's flow analysis
90
- # treat code after the call as unreachable (no
91
- # `flow.unreachable-branch` from us; that's an engine
92
- # rule that consults the same effect lattice).
93
- def self.contribution(call_node, plugin_id)
94
- Rigor::FlowContribution.new(
95
- return_type: Rigor::Type::Combinator.bot,
96
- exceptional: :raises,
97
- provenance: Rigor::FlowContribution::Provenance.new(
98
- source_family: "plugin.#{plugin_id}",
99
- plugin_id: plugin_id,
100
- node: call_node,
101
- descriptor: nil
102
- )
103
- )
104
- end
105
84
  end
106
85
  end
107
86
  end
@@ -6,7 +6,7 @@ module Rigor
6
6
  # Per-run table of method signatures keyed by the
7
7
  # `(class_name, method_name, kind)` triple. Built by
8
8
  # {CatalogWalker} during the plugin's lazy pre-walk; read
9
- # by {Sorbet#flow_contribution_for} at every call site.
9
+ # by the plugin's `dynamic_return` rule at every gated call site.
10
10
  #
11
11
  # The catalog is mutable while it is being built, then
12
12
  # frozen via {#freeze!} before the first read. Construction
@@ -80,6 +80,22 @@ module Rigor
80
80
  @entries.empty?
81
81
  end
82
82
 
83
+ # ADR-52 slice 4 — the distinct method names the catalog
84
+ # carries at least one signature for, across every
85
+ # `(class_name, kind)` owner. Feeds the plugin's run-time
86
+ # `dynamic_return methods:` name gate: the engine only
87
+ # consults the plugin for a call whose name appears here
88
+ # (or in the static assertion vocabulary), and the
89
+ # precise `(class, kind)` lookup stays in the rule block.
90
+ # Computed fresh per call — the plugin memoises the
91
+ # resolved set, and `freeze!` freezes the catalog itself
92
+ # so a lazy memo ivar here would raise.
93
+ #
94
+ # @return [Array<Symbol>]
95
+ def method_names
96
+ @entries.keys.map { |key| key[1] }.uniq
97
+ end
98
+
83
99
  def size
84
100
  @entries.size
85
101
  end
@@ -24,8 +24,8 @@ module Rigor
24
24
  # identically — per-call-site sigil honouring (e.g. only
25
25
  # firing `T.let` recognition in `# typed: true`+ files)
26
26
  # requires threading the file path through
27
- # `flow_contribution_for`, which lives behind a future
28
- # plugin-contract widening slice.
27
+ # the per-call recognition path, which lives behind a
28
+ # future plugin-contract widening slice.
29
29
  module SigilDetector
30
30
  # Sorbet's strictness-level names. Stored as symbols to
31
31
  # match the analyzer's existing convention for level