rigortype 0.1.18 → 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 (89) 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 +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 +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. 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
  )
@@ -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,
@@ -86,10 +86,7 @@ module Rigor
86
86
  ]
87
87
  )
88
88
 
89
- def init(services)
90
- @services = services
91
- @factory_index_resolved = false
92
- @factory_index = nil
89
+ def init(_services)
93
90
  # Per-path `LetScopeIndex` cache. The let-binding
94
91
  # `dynamic_return` rule (and its `file_methods:` gate)
95
92
  # consult the index per call node; building it once
@@ -152,7 +149,7 @@ module Rigor
152
149
  LetTypeResolver.resolve(
153
150
  block_node,
154
151
  describe_const: describe_const,
155
- factory_index: factory_index_or_nil,
152
+ factory_index: read_fact(plugin_id: "factorybot", name: :factory_index),
156
153
  environment: scope.environment
157
154
  )
158
155
  end
@@ -185,14 +182,6 @@ module Rigor
185
182
  nil
186
183
  end
187
184
 
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
185
  def build_diagnostic(diag)
197
186
  Rigor::Analysis::Diagnostic.new(
198
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
  )
@@ -329,7 +329,32 @@ module Rigor
329
329
  chain_lookup(singleton_target, method_name, anchor_kind: :singleton, mixin_kind: :extend)
330
330
  elsif receiver
331
331
  instance_chain_lookup(receiver, method_name, scope)
332
+ else
333
+ implicit_self_lookup(method_name, scope)
334
+ end
335
+ end
336
+
337
+ # ADR-11 slice 2 (deferred from slice 1) — implicit-self calls.
338
+ # A receiver-less call inside a method body resolves against the
339
+ # engine's own `scope.self_type`: `Nominal[Foo]` inside an
340
+ # instance method (instance-side lookup), `Singleton[Foo]` inside
341
+ # a `def self.x` body (singleton-side lookup, `extend` mixins).
342
+ # Without this, an enforced sig on a sibling method was invisible
343
+ # to in-class calls — the engine's body-inference tiers then
344
+ # re-typed the sibling's body, overriding an explicit
345
+ # `T.untyped` opt-out (the dispatcher's plugin tier had already
346
+ # run and declined). Anything else (toplevel / Dynamic / DSL
347
+ # self) contributes nothing and the dispatcher continues.
348
+ def implicit_self_lookup(method_name, scope)
349
+ self_type = scope&.self_type
350
+ case self_type
351
+ when Rigor::Type::Singleton
352
+ chain_lookup(self_type.class_name, method_name, anchor_kind: :singleton, mixin_kind: :extend)
353
+ when Rigor::Type::Nominal
354
+ chain_lookup(self_type.class_name, method_name, anchor_kind: :instance, mixin_kind: :include)
332
355
  end
356
+ rescue StandardError
357
+ nil
333
358
  end
334
359
 
335
360
  def instance_chain_lookup(receiver_node, method_name, scope)
@@ -2,6 +2,9 @@ module Rigor
2
2
  module Analysis
3
3
  module CheckRules
4
4
  def self?.diagnose: (path: String, root: untyped, scope_index: Hash[untyped, Scope]) -> Array[Diagnostic]
5
+ def self?.build_node_collectors: (String path, untyped scope_index) -> Hash[Symbol, untyped]
6
+ def self?.node_collector_driver: (Hash[Symbol, untyped] collectors) -> untyped
7
+ def self?.shadow_verify_converged_collectors: (String path, untyped root, untyped scope_index, Hash[Symbol, untyped]? collectors) -> void
5
8
  end
6
9
 
7
10
  class FactStore
@@ -1,4 +1,4 @@
1
1
  class Rigor::Inference::Builtins::MethodCatalog
2
2
  def initialize: (path: String, ?mutating_selectors: Hash[String, Set[untyped]]) -> void
3
- def reset!: () -> nil
3
+ def reset!: () -> Hash[String, untyped]
4
4
  end
@@ -14,7 +14,7 @@ class Rigor::Plugin::Base
14
14
  # the no-arg `manifest` reads the cached value back.
15
15
  def self.manifest: (**untyped fields) -> Rigor::Plugin::Manifest
16
16
 
17
- def self.producer: (untyped id, ?serialize: untyped, ?deserialize: untyped) { (untyped params) -> untyped } -> Symbol
17
+ def self.producer: (untyped id, ?watch: untyped, ?serialize: untyped, ?deserialize: untyped) { (untyped params) -> untyped } -> Symbol
18
18
  def self.producers: () -> Hash[Symbol, untyped]
19
19
 
20
20
  def self.node_rule: (untyped node_type) { (*untyped) -> untyped } -> untyped
@@ -50,11 +50,14 @@ class Rigor::Plugin::Base
50
50
 
51
51
  # Authoring helpers.
52
52
  def diagnostic: (untyped node, path: untyped, message: untyped, ?severity: untyped, ?rule: untyped, ?location: untyped) -> untyped
53
+ def diagnostics_for: (untyped violations, path: untyped, ?node: untyped) -> Array[untyped]
54
+ def read_fact: (plugin_id: untyped, name: untyped) -> untyped
55
+ def producer_value: (untyped id, ?params: untyped) -> untyped
56
+ def producer_error: (untyped id) -> untyped
53
57
  def manifest: () -> Rigor::Plugin::Manifest
54
58
  def signature_paths: () -> Array[String]
55
59
  def protocol_contracts: () -> untyped
56
60
  def io_boundary: () -> Rigor::Plugin::IoBoundary
57
61
  def cache_for: (untyped producer_id, ?params: untyped, ?descriptor: untyped) -> untyped
58
- def glob_descriptor: (untyped roots, *untyped patterns) -> untyped
59
62
  def plugin_entry: () -> untyped
60
63
  end
@@ -3,7 +3,7 @@ class Rigor::Plugin::Manifest::Consumption
3
3
  end
4
4
 
5
5
  class Rigor::Plugin::Manifest
6
- def initialize: (id: untyped, version: untyped, ?description: untyped, ?config_schema: untyped, ?produces: untyped, ?consumes: untyped, ?owns_receivers: untyped, ?open_receivers: untyped, ?type_node_resolvers: untyped, ?block_as_methods: untyped, ?heredoc_templates: untyped, ?nested_class_templates: untyped, ?trait_registries: untyped, ?external_files: untyped, ?hkt_registrations: untyped, ?hkt_definitions: untyped, ?signature_paths: untyped, ?protocol_contracts: untyped, ?source_rbs_synthesizer: untyped, ?additional_initializers: untyped) -> void
6
+ def initialize: (id: untyped, version: untyped, ?description: untyped, ?config_schema: untyped, ?produces: untyped, ?consumes: untyped, ?owns_receivers: untyped, ?open_receivers: untyped, ?type_node_resolvers: untyped, ?block_as_methods: untyped, ?heredoc_templates: untyped, ?nested_class_templates: untyped, ?trait_registries: untyped, ?hkt_registrations: untyped, ?hkt_definitions: untyped, ?signature_paths: untyped, ?protocol_contracts: untyped, ?source_rbs_synthesizer: untyped, ?additional_initializers: untyped) -> void
7
7
 
8
8
  # Public attribute readers (the full `attr_reader` surface). Plugins
9
9
  # read `manifest.id` / `manifest.protocol_contracts` etc.; declaring
@@ -25,7 +25,6 @@ class Rigor::Plugin::Manifest
25
25
  def heredoc_templates: () -> untyped
26
26
  def nested_class_templates: () -> untyped
27
27
  def trait_registries: () -> untyped
28
- def external_files: () -> untyped
29
28
  def hkt_registrations: () -> untyped
30
29
  def hkt_definitions: () -> untyped
31
30
  def signature_paths: () -> untyped
data/sig/rigor/scope.rbs CHANGED
@@ -22,6 +22,7 @@ module Rigor
22
22
  def in_source_constants: () -> Hash[String, Type::t]
23
23
  def discovered_methods: () -> Hash[String, Hash[Symbol, Symbol]]
24
24
  def discovered_def_nodes: () -> Hash[String, Hash[Symbol, untyped]]
25
+ def discovered_singleton_def_nodes: () -> Hash[String, Hash[Symbol, untyped]]
25
26
  def discovered_def_sources: () -> Hash[String, Hash[Symbol, String]]
26
27
  def discovered_method_visibilities: () -> Hash[String, Hash[Symbol, Symbol]]
27
28
  def discovered_superclasses: () -> Hash[String, String]
@@ -38,6 +39,7 @@ module Rigor
38
39
  attr_reader in_source_constants: Hash[String, Type::t]
39
40
  attr_reader discovered_methods: Hash[String, Hash[Symbol, Symbol]]
40
41
  attr_reader discovered_def_nodes: Hash[String, Hash[Symbol, untyped]]
42
+ attr_reader discovered_singleton_def_nodes: Hash[String, Hash[Symbol, untyped]]
41
43
  attr_reader discovered_def_sources: Hash[String, Hash[Symbol, String]]
42
44
  attr_reader discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]]
43
45
  attr_reader discovered_superclasses: Hash[String, String]
@@ -47,7 +49,7 @@ module Rigor
47
49
 
48
50
  EMPTY: DiscoveryIndex
49
51
 
50
- def with: (?declared_types: Hash[untyped, Type::t], ?class_ivars: Hash[String, Hash[Symbol, Type::t]], ?class_cvars: Hash[String, Hash[Symbol, Type::t]], ?program_globals: Hash[Symbol, Type::t], ?discovered_classes: Hash[String, Type::Singleton], ?in_source_constants: Hash[String, Type::t], ?discovered_methods: Hash[String, Hash[Symbol, Symbol]], ?discovered_def_nodes: Hash[String, Hash[Symbol, untyped]], ?discovered_def_sources: Hash[String, Hash[Symbol, String]], ?discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]], ?discovered_superclasses: Hash[String, String], ?discovered_includes: Hash[String, Array[String]], ?discovered_class_sources: Hash[String, Set[String]], ?data_member_layouts: Hash[String, Array[Symbol]]) -> DiscoveryIndex
52
+ def with: (?declared_types: Hash[untyped, Type::t], ?class_ivars: Hash[String, Hash[Symbol, Type::t]], ?class_cvars: Hash[String, Hash[Symbol, Type::t]], ?program_globals: Hash[Symbol, Type::t], ?discovered_classes: Hash[String, Type::Singleton], ?in_source_constants: Hash[String, Type::t], ?discovered_methods: Hash[String, Hash[Symbol, Symbol]], ?discovered_def_nodes: Hash[String, Hash[Symbol, untyped]], ?discovered_singleton_def_nodes: Hash[String, Hash[Symbol, untyped]], ?discovered_def_sources: Hash[String, Hash[Symbol, String]], ?discovered_method_visibilities: Hash[String, Hash[Symbol, Symbol]], ?discovered_superclasses: Hash[String, String], ?discovered_includes: Hash[String, Array[String]], ?discovered_class_sources: Hash[String, Set[String]], ?data_member_layouts: Hash[String, Array[Symbol]]) -> DiscoveryIndex
51
53
  end
52
54
 
53
55
  class IndexedKey
@@ -75,10 +77,17 @@ module Rigor
75
77
  def with_ivar: (String | Symbol name, Type::t type) -> Scope
76
78
  def with_cvar: (String | Symbol name, Type::t type) -> Scope
77
79
  def with_global: (String | Symbol name, Type::t type) -> Scope
80
+ def declaration_sourced: () -> Set[[Symbol, Symbol]]
81
+ def seed_declaration_sourced_ivar: (String | Symbol name, Type::t type) -> Scope
82
+ def with_declaration_sourced_local: (String | Symbol name, Type::t type) -> Scope
83
+ def with_local_declaration_mark: (String | Symbol name) -> Scope
84
+ def declaration_sourced?: (Symbol kind, String | Symbol name) -> bool
85
+ def forget_match_globals: () -> Scope
78
86
  def class_ivars_for: (String | Symbol? class_name) -> Hash[Symbol, Type::t]
79
87
  def class_cvars_for: (String | Symbol? class_name) -> Hash[Symbol, Type::t]
80
88
  def discovered_method?: (String | Symbol class_name, String | Symbol method_name, Symbol kind) -> bool
81
89
  def user_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
90
+ def singleton_def_for: (String | Symbol class_name, String | Symbol method_name) -> untyped?
82
91
  def user_def_site_for: (String | Symbol class_name, String | Symbol method_name) -> String?
83
92
  def top_level_def_for: (String | Symbol method_name) -> untyped?
84
93
  def toplevel?: () -> bool
data/sig/rigor/type.rbs CHANGED
@@ -342,6 +342,7 @@ module Rigor
342
342
  def self?.union: (*Type::t types) -> Type::t
343
343
  def self?.key_of: (Type::t type) -> Type::t
344
344
  def self?.value_of: (Type::t type) -> Type::t
345
+ def self?.widen_value_pinned: (Type::t type) -> Type::t
345
346
  def self?.int_mask: (Array[Integer] flags) -> Type::t?
346
347
  def self?.int_mask_of: (Type::t type) -> Type::t?
347
348
  def self?.indexed_access: (Type::t type, Type::t key) -> Type::t
data/sig/rigor.rbs CHANGED
@@ -11,7 +11,7 @@ module Rigor
11
11
  attr_reader cache_path: String
12
12
  attr_reader baseline_path: String?
13
13
 
14
- def self.load: (?String path) -> Configuration
14
+ def self.load: (?String? path) -> Configuration
15
15
  def self.discover: () -> String?
16
16
  def self.load_with_includes: (String path, ?visited: Set[String]) -> Hash[String, untyped]
17
17
  def initialize: (?Hash[String, untyped] data) -> void
@@ -28,6 +28,33 @@ signal — each hint has an `id`:
28
28
  | `project-monkey-patch` | A DSL / monkey-patch Rigor can't see. | Escalate — a `pre_eval:` entry or a plugin clears the whole cluster. |
29
29
  | `activerecord-relation-misinference` | Likely an engine gap. | Treat sites as candidate false positives (Phase 2). |
30
30
 
31
+ ### `rigor triage --format json` `.selectors` — the by-(class, method) axis
32
+
33
+ Beside `hints`, the triage JSON carries a `selectors` array: one row
34
+ per dispatch target the diagnostics cluster on, built from the
35
+ structured `receiver_type` / `method_name` fields (never message
36
+ parsing). Each row is `{receiver, method, count, files, rules}`. Use
37
+ it to pick *which sites within a rule* to sample first — and to tell a
38
+ systemic cause from a scatter of real bugs — with `jq`, not eyeballing
39
+ the stream:
40
+
41
+ ```sh
42
+ # the dispatch targets responsible for the most diagnostics
43
+ rigor triage --format json | jq -r '.selectors[:15][] | "\(.count)\t\(.files)f\t\(.receiver)#\(.method)"'
44
+ # one method, one receiver, spread across many files → systemic
45
+ # (a plugin / pre_eval clears it) rather than N independent bugs
46
+ rigor triage --format json | jq '.selectors[] | select(.files >= 4)'
47
+ # narrow to a rule you are about to work, ranked by concentration
48
+ rigor triage --format json \
49
+ | jq '[.selectors[] | select(.rules["call.possible-nil-receiver"])] | sort_by(-.count)'
50
+ ```
51
+
52
+ Read it as: **high `count` × high `files` = a systemic selector**
53
+ (escalate as a decision — one fix clears the cluster); **low `count` =
54
+ a candidate genuine bug** to sample directly in Phase 2. The `receiver`
55
+ is a normalised class (`"hi".squish` and `name.squish` both bucket
56
+ under `String#squish`), so a single idiom does not scatter across rows.
57
+
31
58
  ### `rigor baseline dump --format json` — the bucket list
32
59
 
33
60
  ```sh
@@ -98,9 +98,11 @@ AST walk per file — hands every matching node to the block along with a
98
98
  `Rigor::Analysis::Diagnostic` (built via the `diagnostic` helper).
99
99
  Optionally the plugin also declares `dynamic_return(receivers:)` /
100
100
  `type_specifier(methods:)` to *supply* a return type or narrowing facts
101
- for call sites the core analyzer types as `Dynamic`. (The older
102
- `#diagnostics_for_file` / `#flow_contribution_for` fat hooks remain as
103
- deprecated escape valves see Phase 2.)
101
+ for call sites the core analyzer types as `Dynamic`. `#diagnostics_for_file`
102
+ is the file-rule surface for whole-file diagnostics a per-node walk can't
103
+ express. (`flow_contribution_for` was removed pre-1.0 in ADR-52 WD3 —
104
+ defining it now raises `ArgumentError`; use `dynamic_return` /
105
+ `type_specifier`. See Phase 2.)
104
106
 
105
107
  ## Phase outline
106
108
 
@@ -115,5 +117,5 @@ deprecated escape valves — see Phase 2.)
115
117
  | Module | Read | Covers |
116
118
  | --- | --- | --- |
117
119
  | 1 | [`references/01-plan-and-scaffold.md`](references/01-plan-and-scaffold.md) | **Phase 1.** The gem vs project-private packaging split, directory trees for both, gemspec template, project-private path-gem / `RUBYLIB` activation, the `Rigor::Plugin::Base` skeleton, `.rigor.yml` `plugins:` wiring. |
118
- | 2 | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) | **Phase 2.** The `node_rule` engine-owned AST walk over Prism nodes, the `Base#diagnostic` helper, asking the analyzer for inferred types via `scope.type_of`, two-pass / lexical context (`node_file_context` / `NodeContext`), the optional `dynamic_return` / `type_specifier` return-type hooks (and the deprecated `flow_contribution_for` escape valve), calling the target library's pure methods directly rather than reimplementing them (ADR-39: `Plugin::Inflector` over the real `ActiveSupport::Inflector`; `Base.suggest` for did-you-mean), and shipping `sig/*.rbs` so the DSL's types are visible. |
120
+ | 2 | [`references/02-walker-and-types.md`](references/02-walker-and-types.md) | **Phase 2.** The `node_rule` engine-owned AST walk over Prism nodes, the `Base#diagnostic` helper, asking the analyzer for inferred types via `scope.type_of`, two-pass / lexical context (`node_file_context` / `NodeContext`), the optional `dynamic_return` / `type_specifier` return-type hooks (`flow_contribution_for` was removed pre-1.0 in ADR-52 WD3), calling the target library's pure methods directly rather than reimplementing them (ADR-39: `Plugin::Inflector` over the real `ActiveSupport::Inflector`; `Base.suggest` for did-you-mean), and shipping `sig/*.rbs` so the DSL's types are visible. |
119
121
  | 3 | [`references/03-test-and-ship.md`](references/03-test-and-ship.md) | **Phase 3.** Testing a plugin from outside the monorepo — fixture projects driven through `rigor check --format json`, plus pure unit tests of dispatch tables — with RSpec or Minitest. Version pinning against the pre-1.0 contract. README. Publishing to RubyGems or keeping the plugin private. |