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.
- checksums.yaml +4 -4
- data/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -7,10 +7,12 @@ require_relative "../source/node_walker"
|
|
|
7
7
|
require_relative "../type"
|
|
8
8
|
require_relative "diagnostic"
|
|
9
9
|
require_relative "dependency_recorder"
|
|
10
|
+
require_relative "check_rules/rule_walk"
|
|
10
11
|
require_relative "check_rules/always_truthy_condition_collector"
|
|
11
12
|
require_relative "check_rules/unreachable_clause_collector"
|
|
12
13
|
require_relative "check_rules/dead_assignment_collector"
|
|
13
14
|
require_relative "check_rules/ivar_write_collector"
|
|
15
|
+
require_relative "check_rules/main_pass_collector"
|
|
14
16
|
require_relative "check_rules/self_closedness_scanner"
|
|
15
17
|
|
|
16
18
|
module Rigor
|
|
@@ -162,32 +164,149 @@ module Rigor
|
|
|
162
164
|
# @param root [Prism::Node]
|
|
163
165
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
164
166
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
165
|
-
|
|
167
|
+
#
|
|
168
|
+
# ADR-53 B4 — when `node_collectors` is supplied, the converged
|
|
169
|
+
# {Plugin::NodeRuleWalk} traversal has already populated the built-in
|
|
170
|
+
# collectors (including the main pass) in one shared walk with the
|
|
171
|
+
# plugin node-rules, so they are consumed as-is. When it is nil (a
|
|
172
|
+
# direct caller with no plugin walk, e.g. a unit test), the standalone
|
|
173
|
+
# {RuleWalk} walk runs here instead, so `diagnose` stays correct
|
|
174
|
+
# without the converged path.
|
|
175
|
+
def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [],
|
|
176
|
+
node_collectors: nil)
|
|
177
|
+
collectors = node_collectors || run_node_collectors(path, root, scope_index)
|
|
178
|
+
diagnostics = collectors[:main_pass].results.dup
|
|
179
|
+
diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
|
|
180
|
+
diagnostics.concat(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
|
|
181
|
+
diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
|
|
182
|
+
diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
|
|
183
|
+
diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
|
|
184
|
+
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The verbatim per-node dispatch of the former inline main pass
|
|
188
|
+
# (`diagnose`'s `Source::NodeWalker.each` `case`), now invoked by
|
|
189
|
+
# {MainPassCollector} on the shared {RuleWalk}. Returns the
|
|
190
|
+
# diagnostics for one node, in the same emission order as before.
|
|
191
|
+
def main_pass_node_diagnostics(path, node, scope_index)
|
|
192
|
+
case node
|
|
193
|
+
when Prism::CallNode
|
|
194
|
+
call_node_diagnostics(path, node, scope_index)
|
|
195
|
+
when Prism::DefNode
|
|
196
|
+
[
|
|
197
|
+
return_type_mismatch_diagnostic(path, node, scope_index),
|
|
198
|
+
override_visibility_diagnostic(path, node, scope_index),
|
|
199
|
+
override_return_widened_diagnostic(path, node, scope_index),
|
|
200
|
+
override_param_narrowed_diagnostic(path, node, scope_index)
|
|
201
|
+
].compact
|
|
202
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
203
|
+
[unreachable_branch_diagnostic(path, node, scope_index)].compact
|
|
204
|
+
else
|
|
205
|
+
[]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Constructs the fresh, unpopulated built-in collector set keyed by
|
|
210
|
+
# role, including the main pass. Split out so the converged walk
|
|
211
|
+
# (ADR-53 B4) can build the collectors, drive them via a
|
|
212
|
+
# {RuleWalk::CollectorDriver} inside the single {Plugin::NodeRuleWalk}
|
|
213
|
+
# traversal, and hand the populated set back to {.diagnose} as
|
|
214
|
+
# `node_collectors:`. The main pass needs `path` because its per-node
|
|
215
|
+
# diagnostics carry it (ADR-53 B3c hosts it on the same walk).
|
|
216
|
+
def build_node_collectors(path, scope_index)
|
|
217
|
+
{
|
|
218
|
+
main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
|
|
219
|
+
always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
|
|
220
|
+
unreachable_clauses: UnreachableClauseCollector.new(scope_index),
|
|
221
|
+
ivar_writes: IvarWriteCollector.new(scope_index),
|
|
222
|
+
dead_assignments: DeadAssignmentCollector.new(scope_index)
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A {RuleWalk::CollectorDriver} over a built-in collector set, for a
|
|
227
|
+
# foreign traversal to drive (ADR-53 B4). The driver visits each node
|
|
228
|
+
# and derives child contexts exactly as the standalone {RuleWalk}
|
|
229
|
+
# walk would.
|
|
230
|
+
def node_collector_driver(collectors)
|
|
231
|
+
RuleWalk::CollectorDriver.new(collectors.values)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# ADR-53 Track B — the {RuleWalk}-hosted built-in collectors (the main
|
|
235
|
+
# pass and the four fact collectors) all ride one traversal of the
|
|
236
|
+
# file instead of one walk each. Returns the populated collectors
|
|
237
|
+
# keyed by role so the caller can build the diagnostics from each
|
|
238
|
+
# collector's `results`. Used on the standalone path (no converged
|
|
239
|
+
# plugin walk); the converged path populates the same collector set
|
|
240
|
+
# via {.node_collector_driver} instead.
|
|
241
|
+
#
|
|
242
|
+
# Under `RIGOR_SHADOW_RULE_WALK=1` each hosted collector's legacy
|
|
243
|
+
# single-collector `#collect` walk also runs as the oracle and any
|
|
244
|
+
# divergence aborts the run — the corpus-scale half of the
|
|
245
|
+
# equivalence harness (the curated half is `rule_walk_equivalence_spec`).
|
|
246
|
+
def run_node_collectors(path, root, scope_index)
|
|
247
|
+
collectors = build_node_collectors(path, scope_index)
|
|
248
|
+
RuleWalk.run(root, collectors.values)
|
|
249
|
+
shadow_verify_node_collectors(path, root, scope_index, collectors) if ENV["RIGOR_SHADOW_RULE_WALK"]
|
|
250
|
+
collectors
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def shadow_verify_node_collectors(path, root, scope_index, collectors)
|
|
254
|
+
divergences = collectors.filter_map do |role, collector|
|
|
255
|
+
legacy = oracle_results(role, collector, path, root, scope_index)
|
|
256
|
+
next if comparable(legacy) == comparable(collector.results)
|
|
257
|
+
|
|
258
|
+
"#{role} legacy=#{legacy.size} walk=#{collector.results.size}"
|
|
259
|
+
end
|
|
260
|
+
return if divergences.empty?
|
|
261
|
+
|
|
262
|
+
raise "RIGOR_SHADOW_RULE_WALK divergence: #{divergences.join('; ')}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Normalises a collector's result for value comparison. The fact
|
|
266
|
+
# collectors return `Data` / Hash structures that already compare by
|
|
267
|
+
# value; the main pass returns {Diagnostic} objects (plain objects
|
|
268
|
+
# with identity `==`), so serialise those to hashes first.
|
|
269
|
+
def comparable(results)
|
|
270
|
+
return results.map(&:to_h) if results.is_a?(Array) && results.first.is_a?(Diagnostic)
|
|
271
|
+
|
|
272
|
+
results
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The oracle each hosted collector's walk result is checked against.
|
|
276
|
+
# The fact collectors re-run their legacy single-collector `#collect`
|
|
277
|
+
# walk; the main pass re-runs the former inline `Source::NodeWalker`
|
|
278
|
+
# `case` (`main_pass_oracle`) since its diagnostics are the result.
|
|
279
|
+
def oracle_results(role, collector, path, root, scope_index)
|
|
280
|
+
return main_pass_oracle(path, root, scope_index) if role == :main_pass
|
|
281
|
+
|
|
282
|
+
collector.class.new(scope_index).collect(root)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The former inline main pass, kept as the shadow oracle: walks the
|
|
286
|
+
# tree with `Source::NodeWalker.each` and accumulates the same
|
|
287
|
+
# per-node diagnostics in the same order {MainPassCollector} now
|
|
288
|
+
# produces them on the shared walk.
|
|
289
|
+
def main_pass_oracle(path, root, scope_index)
|
|
166
290
|
diagnostics = []
|
|
167
291
|
Source::NodeWalker.each(root) do |node|
|
|
168
|
-
|
|
169
|
-
when Prism::CallNode
|
|
170
|
-
diagnostics.concat(call_node_diagnostics(path, node, scope_index))
|
|
171
|
-
when Prism::DefNode
|
|
172
|
-
return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
|
|
173
|
-
diagnostics << return_diagnostic if return_diagnostic
|
|
174
|
-
override_vis = override_visibility_diagnostic(path, node, scope_index)
|
|
175
|
-
diagnostics << override_vis if override_vis
|
|
176
|
-
override_return = override_return_widened_diagnostic(path, node, scope_index)
|
|
177
|
-
diagnostics << override_return if override_return
|
|
178
|
-
override_param = override_param_narrowed_diagnostic(path, node, scope_index)
|
|
179
|
-
diagnostics << override_param if override_param
|
|
180
|
-
when Prism::IfNode, Prism::UnlessNode
|
|
181
|
-
unreachable = unreachable_branch_diagnostic(path, node, scope_index)
|
|
182
|
-
diagnostics << unreachable if unreachable
|
|
183
|
-
end
|
|
292
|
+
diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
|
|
184
293
|
end
|
|
185
|
-
diagnostics
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
294
|
+
diagnostics
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# ADR-53 B4 — corpus-scale oracle for the CONVERGED walk: the
|
|
298
|
+
# collectors (including the main pass, ADR-53 B3c) were populated by
|
|
299
|
+
# the {Plugin::NodeRuleWalk} traversal, not by `RuleWalk.run`, so
|
|
300
|
+
# re-run each collector's legacy oracle (the fact collectors'
|
|
301
|
+
# `#collect` walk, the main pass's inline `Source::NodeWalker` `case`)
|
|
302
|
+
# and assert the converged walk produced byte-identical results. Same
|
|
303
|
+
# divergence contract as {.shadow_verify_node_collectors}; nil
|
|
304
|
+
# collectors (caller without built-in collection) is a no-op. `path`
|
|
305
|
+
# is threaded because the main pass's oracle carries it.
|
|
306
|
+
def shadow_verify_converged_collectors(path, root, scope_index, collectors)
|
|
307
|
+
return if collectors.nil?
|
|
308
|
+
|
|
309
|
+
shadow_verify_node_collectors(path, root, scope_index, collectors)
|
|
191
310
|
end
|
|
192
311
|
|
|
193
312
|
def call_node_diagnostics(path, node, scope_index)
|
|
@@ -224,8 +343,8 @@ module Rigor
|
|
|
224
343
|
# Class-level ivars (`@x = 1` outside any def, in the
|
|
225
344
|
# class body) are also skipped — they're a separate
|
|
226
345
|
# surface (`Module#@var`) the engine doesn't yet model.
|
|
227
|
-
def ivar_write_mismatch_diagnostics(path,
|
|
228
|
-
|
|
346
|
+
def ivar_write_mismatch_diagnostics(path, ivar_writes)
|
|
347
|
+
ivar_writes.flat_map do |class_name, writes_by_ivar|
|
|
229
348
|
writes_by_ivar.flat_map do |ivar_name, writes|
|
|
230
349
|
ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
|
|
231
350
|
end
|
|
@@ -238,8 +357,8 @@ module Rigor
|
|
|
238
357
|
# read in the same body. The
|
|
239
358
|
# `Analysis::CheckRules::DeadAssignmentCollector` describes
|
|
240
359
|
# the conservative envelope.
|
|
241
|
-
def dead_assignment_diagnostics(path,
|
|
242
|
-
|
|
360
|
+
def dead_assignment_diagnostics(path, dead_assignments)
|
|
361
|
+
dead_assignments.map do |result|
|
|
243
362
|
build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
|
|
244
363
|
end
|
|
245
364
|
end
|
|
@@ -251,8 +370,8 @@ module Rigor
|
|
|
251
370
|
# predicate skip envelope (see
|
|
252
371
|
# `Analysis::CheckRules::AlwaysTruthyConditionCollector`
|
|
253
372
|
# for the full triage rationale).
|
|
254
|
-
def always_truthy_condition_diagnostics(path,
|
|
255
|
-
|
|
373
|
+
def always_truthy_condition_diagnostics(path, results)
|
|
374
|
+
results.map do |result|
|
|
256
375
|
build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
|
|
257
376
|
end
|
|
258
377
|
end
|
|
@@ -261,8 +380,8 @@ module Rigor
|
|
|
261
380
|
# the flow engine's narrowing proves can never match (its narrowed
|
|
262
381
|
# subject is `bot`). The squiggle lands on the dead clause's body,
|
|
263
382
|
# mirroring `flow.unreachable-branch`.
|
|
264
|
-
def unreachable_clause_diagnostics(path,
|
|
265
|
-
|
|
383
|
+
def unreachable_clause_diagnostics(path, results)
|
|
384
|
+
results.map do |result|
|
|
266
385
|
build_unreachable_clause_diagnostic(path, result)
|
|
267
386
|
end
|
|
268
387
|
end
|
|
@@ -393,7 +512,18 @@ module Rigor
|
|
|
393
512
|
scope = scope_index[call_node]
|
|
394
513
|
return nil if scope.nil?
|
|
395
514
|
|
|
396
|
-
|
|
515
|
+
# N3 — a safe-navigation call (`recv&.m`) never dispatches on the
|
|
516
|
+
# nil edge of its receiver: at runtime it short-circuits to nil.
|
|
517
|
+
# A receiver that types as exactly `nil` yields nil with no call at
|
|
518
|
+
# all, so it is silent (no dead-code diagnostic — `&.` is the
|
|
519
|
+
# nil-skip operator by design, and frightening working
|
|
520
|
+
# `@x = nil; @x&.m` code would breach FP discipline). A nil-bearing
|
|
521
|
+
# *union* receiver is left to flow through unchanged: a `T | nil`
|
|
522
|
+
# union has no single concrete class, so the rule already bails
|
|
523
|
+
# below — preserving that keeps `&.` from newly firing on the
|
|
524
|
+
# non-nil constituent (which, for a cross-file project def, would
|
|
525
|
+
# be a working-code false positive).
|
|
526
|
+
receiver_type = safe_navigation_receiver(call_node, scope)
|
|
397
527
|
class_name = concrete_class_name(receiver_type)
|
|
398
528
|
return nil if class_name.nil?
|
|
399
529
|
|
|
@@ -714,7 +844,7 @@ module Rigor
|
|
|
714
844
|
# by `undefined_method_diagnostic`; it returns nil
|
|
715
845
|
# when the call's receiver / RBS coverage / call shape
|
|
716
846
|
# disqualifies the rule.
|
|
717
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
847
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
718
848
|
def wrong_arity_diagnostic(path, call_node, scope_index)
|
|
719
849
|
return nil if call_node.receiver.nil?
|
|
720
850
|
return nil unless plain_positional_call?(call_node)
|
|
@@ -727,6 +857,15 @@ module Rigor
|
|
|
727
857
|
return nil if class_name.nil?
|
|
728
858
|
|
|
729
859
|
kind = receiver_type.is_a?(Type::Singleton) ? :singleton : :instance
|
|
860
|
+
# `Struct.new(:a, :b).new(...)` chained: the inner
|
|
861
|
+
# `Struct.new(...)` is an anonymous Struct *subclass* whose
|
|
862
|
+
# `.new` accepts any positional arity (one slot per member,
|
|
863
|
+
# all defaulting to nil) — including zero. The receiver types
|
|
864
|
+
# as `Singleton[Struct]` (so the call-site `.new` dispatches,
|
|
865
|
+
# per the dispatcher's `struct_new_lift`), but validating that
|
|
866
|
+
# `.new` against the real `Struct.new(*Symbol)` signature is a
|
|
867
|
+
# false positive. Skip arity-checking the chained position.
|
|
868
|
+
return nil if anonymous_struct_new_call?(call_node, class_name, kind)
|
|
730
869
|
return nil if scope.discovered_method?(class_name, call_node.name, kind)
|
|
731
870
|
|
|
732
871
|
return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
|
|
@@ -744,7 +883,25 @@ module Rigor
|
|
|
744
883
|
|
|
745
884
|
build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
746
885
|
end
|
|
747
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
886
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
887
|
+
|
|
888
|
+
# True for the outer `.new` of a chained `Struct.new(...).new`:
|
|
889
|
+
# `class_name`/`kind` already pin the receiver to
|
|
890
|
+
# `Singleton[Struct]`, and the receiver node is itself a
|
|
891
|
+
# `Struct.new` (or `::Struct.new`) call — the anonymous subclass.
|
|
892
|
+
def anonymous_struct_new_call?(call_node, class_name, kind)
|
|
893
|
+
return false unless class_name == "Struct" && kind == :singleton
|
|
894
|
+
return false unless call_node.name == :new
|
|
895
|
+
|
|
896
|
+
receiver = call_node.receiver
|
|
897
|
+
return false unless receiver.is_a?(Prism::CallNode) && receiver.name == :new
|
|
898
|
+
|
|
899
|
+
inner = receiver.receiver
|
|
900
|
+
return true if inner.is_a?(Prism::ConstantReadNode) && inner.name == :Struct
|
|
901
|
+
|
|
902
|
+
# `::Struct.new(...).new` — a top-level constant path.
|
|
903
|
+
inner.is_a?(Prism::ConstantPathNode) && inner.parent.nil? && inner.name == :Struct
|
|
904
|
+
end
|
|
748
905
|
|
|
749
906
|
def plain_positional_call?(call_node)
|
|
750
907
|
arguments = call_node.arguments
|
|
@@ -836,6 +993,17 @@ module Rigor
|
|
|
836
993
|
scope = scope_index[call_node]
|
|
837
994
|
return nil if scope.nil?
|
|
838
995
|
|
|
996
|
+
# ADR-58 WD1 — a receiver whose `nil` constituent is purely
|
|
997
|
+
# declaration-sourced (the class-ivar index seed of a ctor
|
|
998
|
+
# `@x = nil` written in another method, possibly copied into a
|
|
999
|
+
# local via `r = @right`) does not fire by default: the working
|
|
1000
|
+
# program's cross-method invariant is assumed per the robustness
|
|
1001
|
+
# principle. The nil stays in the displayed type; only its use as
|
|
1002
|
+
# diagnostic fuel is withheld. Any flow-live touch (method-local
|
|
1003
|
+
# nil write, failed-guard narrowing) drops the mark upstream, so
|
|
1004
|
+
# flow-observed nil keeps firing exactly as before.
|
|
1005
|
+
return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
|
|
1006
|
+
|
|
839
1007
|
receiver_type = scope.type_of(call_node.receiver)
|
|
840
1008
|
return nil unless receiver_type.is_a?(Type::Union)
|
|
841
1009
|
|
|
@@ -855,6 +1023,21 @@ module Rigor
|
|
|
855
1023
|
union.members.any? { |member| nil_member?(member) }
|
|
856
1024
|
end
|
|
857
1025
|
|
|
1026
|
+
# The receiver type the `call.undefined-method` existence check
|
|
1027
|
+
# should reason about. For a safe-navigation call whose receiver
|
|
1028
|
+
# types as exactly `nil`, this is `Type::Bot` — the call is
|
|
1029
|
+
# statically skipped at runtime, and `concrete_class_name(Bot)` is
|
|
1030
|
+
# nil so the rule bails (silent). Every other receiver (including a
|
|
1031
|
+
# `T | nil` union, which already has no single concrete class) flows
|
|
1032
|
+
# through unchanged.
|
|
1033
|
+
def safe_navigation_receiver(call_node, scope)
|
|
1034
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
1035
|
+
return receiver_type unless call_node.safe_navigation?
|
|
1036
|
+
return receiver_type unless nil_member?(receiver_type)
|
|
1037
|
+
|
|
1038
|
+
Type::Combinator.bot
|
|
1039
|
+
end
|
|
1040
|
+
|
|
858
1041
|
def nil_member?(member)
|
|
859
1042
|
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
860
1043
|
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
@@ -1020,7 +1203,8 @@ module Rigor
|
|
|
1020
1203
|
rule: RULE_NIL_RECEIVER,
|
|
1021
1204
|
path: path,
|
|
1022
1205
|
message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
|
|
1023
|
-
severity: :error
|
|
1206
|
+
severity: :error,
|
|
1207
|
+
method_name: call_node.name.to_s
|
|
1024
1208
|
)
|
|
1025
1209
|
end
|
|
1026
1210
|
|
|
@@ -1208,7 +1392,9 @@ module Rigor
|
|
|
1208
1392
|
rule: RULE_VISIBILITY_MISMATCH,
|
|
1209
1393
|
path: path,
|
|
1210
1394
|
message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
|
|
1211
|
-
severity: :error
|
|
1395
|
+
severity: :error,
|
|
1396
|
+
receiver_type: receiver_type.class_name,
|
|
1397
|
+
method_name: call_node.name.to_s
|
|
1212
1398
|
)
|
|
1213
1399
|
end
|
|
1214
1400
|
|
|
@@ -1407,12 +1593,49 @@ module Rigor
|
|
|
1407
1593
|
arg_type = scope.type_of(arg)
|
|
1408
1594
|
next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
|
|
1409
1595
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1596
|
+
next unless argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
|
|
1597
|
+
|
|
1598
|
+
return { node: arg, name: param.name, expected: param_type, actual: arg_type }
|
|
1412
1599
|
end
|
|
1413
1600
|
nil
|
|
1414
1601
|
end
|
|
1415
1602
|
|
|
1603
|
+
# The parameter rejects the argument AND the rejection is not a
|
|
1604
|
+
# withheld declaration-sourced-nil case.
|
|
1605
|
+
def argument_genuinely_mismatches?(arg, arg_type, param_type, scope)
|
|
1606
|
+
return false unless Inference::Acceptance.accepts(param_type, arg_type, mode: :gradual).no?
|
|
1607
|
+
|
|
1608
|
+
# ADR-58 (N2 extension) — the same declaration-sourced-nil-is-not-
|
|
1609
|
+
# diagnostic-fuel criterion that governs `possible-nil-receiver`
|
|
1610
|
+
# applies here. When the only reason the argument is rejected is a
|
|
1611
|
+
# *declaration-sourced* nil constituent (the class-ivar index seed
|
|
1612
|
+
# of a ctor `@x = nil` / a non-definitely-assigned ivar read), and
|
|
1613
|
+
# the argument type with that nil removed WOULD be accepted, the
|
|
1614
|
+
# working program's cross-method invariant is assumed and we do not
|
|
1615
|
+
# fire. Flow-live nil (a method-local `@x = nil` write, a failed-
|
|
1616
|
+
# guard narrowing) drops the provenance mark upstream and still
|
|
1617
|
+
# fires. The argument's type is unchanged — only the firing
|
|
1618
|
+
# decision is gated.
|
|
1619
|
+
!declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
# True when `arg` is a declaration-sourced ivar read whose rejection is
|
|
1623
|
+
# caused solely by its nil constituent: stripping nil from the argument
|
|
1624
|
+
# type yields a type the parameter accepts (gradual mode). Mirrors the
|
|
1625
|
+
# `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
|
|
1626
|
+
# rather than a local copy.
|
|
1627
|
+
def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
1628
|
+
return false unless arg.is_a?(Prism::InstanceVariableReadNode)
|
|
1629
|
+
return false unless scope.declaration_sourced?(:ivar, arg.name)
|
|
1630
|
+
return false unless arg_type.is_a?(Type::Union)
|
|
1631
|
+
return false unless union_contains_nil?(arg_type)
|
|
1632
|
+
|
|
1633
|
+
non_nil = Type::Combinator.union(*arg_type.members.reject { |m| nil_member?(m) })
|
|
1634
|
+
return false if non_nil.is_a?(Type::Bot)
|
|
1635
|
+
|
|
1636
|
+
Inference::Acceptance.accepts(param_type, non_nil, mode: :gradual).yes?
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1416
1639
|
def argument_check_eligible?(function)
|
|
1417
1640
|
# See `arity_eligible?`: `UntypedFunction` lacks
|
|
1418
1641
|
# the per-arity accessors. Treat it as ineligible
|
|
@@ -1443,7 +1666,9 @@ module Rigor
|
|
|
1443
1666
|
rule: RULE_ARGUMENT_TYPE,
|
|
1444
1667
|
path: path,
|
|
1445
1668
|
message: message,
|
|
1446
|
-
severity: :error
|
|
1669
|
+
severity: :error,
|
|
1670
|
+
receiver_type: class_name,
|
|
1671
|
+
method_name: call_node.name.to_s
|
|
1447
1672
|
)
|
|
1448
1673
|
end
|
|
1449
1674
|
|
|
@@ -1456,7 +1681,9 @@ module Rigor
|
|
|
1456
1681
|
rule: RULE_WRONG_ARITY,
|
|
1457
1682
|
path: path,
|
|
1458
1683
|
message: message,
|
|
1459
|
-
severity: :error
|
|
1684
|
+
severity: :error,
|
|
1685
|
+
receiver_type: class_name,
|
|
1686
|
+
method_name: call_node.name.to_s
|
|
1460
1687
|
)
|
|
1461
1688
|
end
|
|
1462
1689
|
|
|
@@ -1629,7 +1856,8 @@ module Rigor
|
|
|
1629
1856
|
path: path,
|
|
1630
1857
|
message: "return-type mismatch on `#{def_node.name}': " \
|
|
1631
1858
|
"declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
|
|
1632
|
-
severity: severity
|
|
1859
|
+
severity: severity,
|
|
1860
|
+
method_name: def_node.name.to_s
|
|
1633
1861
|
)
|
|
1634
1862
|
end
|
|
1635
1863
|
|
|
@@ -1781,7 +2009,8 @@ module Rigor
|
|
|
1781
2009
|
message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
|
|
1782
2010
|
"#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
|
|
1783
2011
|
"breaks substitutability",
|
|
1784
|
-
severity: :warning
|
|
2012
|
+
severity: :warning,
|
|
2013
|
+
method_name: def_node.name.to_s
|
|
1785
2014
|
)
|
|
1786
2015
|
end
|
|
1787
2016
|
|
|
@@ -1874,7 +2103,8 @@ module Rigor
|
|
|
1874
2103
|
message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
|
|
1875
2104
|
"to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
|
|
1876
2105
|
"breaks substitutability",
|
|
1877
|
-
severity: :warning
|
|
2106
|
+
severity: :warning,
|
|
2107
|
+
method_name: def_node.name.to_s
|
|
1878
2108
|
)
|
|
1879
2109
|
end
|
|
1880
2110
|
|
|
@@ -1978,7 +2208,8 @@ module Rigor
|
|
|
1978
2208
|
message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
|
|
1979
2209
|
"#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
|
|
1980
2210
|
"(overrides #{parent_class}##{def_node.name}); breaks substitutability",
|
|
1981
|
-
severity: :warning
|
|
2211
|
+
severity: :warning,
|
|
2212
|
+
method_name: def_node.name.to_s
|
|
1982
2213
|
)
|
|
1983
2214
|
end
|
|
1984
2215
|
end
|
|
@@ -132,6 +132,12 @@ module Rigor
|
|
|
132
132
|
"#{source_family}.#{rule}"
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# `--format json` serialisation. The structured `receiver_type`
|
|
136
|
+
# / `method_name` / `project_definition_site` fields are emitted
|
|
137
|
+
# only when populated, so a consumer (`jq`, `rigor triage`, an AI
|
|
138
|
+
# agent) can group a `rigor check --format json` stream by the
|
|
139
|
+
# called class / method without parsing the human-readable
|
|
140
|
+
# `message` — the message wording is presentation, not contract.
|
|
135
141
|
def to_h
|
|
136
142
|
base = {
|
|
137
143
|
"path" => path,
|
|
@@ -142,6 +148,8 @@ module Rigor
|
|
|
142
148
|
"source_family" => source_family.to_s,
|
|
143
149
|
"message" => message
|
|
144
150
|
}
|
|
151
|
+
base["receiver_type"] = receiver_type if receiver_type
|
|
152
|
+
base["method_name"] = method_name if method_name
|
|
145
153
|
base["project_definition_site"] = project_definition_site if project_definition_site
|
|
146
154
|
base
|
|
147
155
|
end
|