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.
- checksums.yaml +4 -4
- data/README.md +159 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- 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 +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -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 +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- 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/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 +72 -1
- 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 +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- 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 +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -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.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- 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.rb +2 -13
- 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.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- 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-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 +7 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -12,6 +12,7 @@ require_relative "check_rules/always_truthy_condition_collector"
|
|
|
12
12
|
require_relative "check_rules/unreachable_clause_collector"
|
|
13
13
|
require_relative "check_rules/dead_assignment_collector"
|
|
14
14
|
require_relative "check_rules/ivar_write_collector"
|
|
15
|
+
require_relative "check_rules/main_pass_collector"
|
|
15
16
|
require_relative "check_rules/self_closedness_scanner"
|
|
16
17
|
|
|
17
18
|
module Rigor
|
|
@@ -163,59 +164,149 @@ module Rigor
|
|
|
163
164
|
# @param root [Prism::Node]
|
|
164
165
|
# @param scope_index [Hash{Prism::Node => Rigor::Scope}]
|
|
165
166
|
# @return [Array<Rigor::Analysis::Diagnostic>]
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
diagnostics << override_return if override_return
|
|
179
|
-
override_param = override_param_narrowed_diagnostic(path, node, scope_index)
|
|
180
|
-
diagnostics << override_param if override_param
|
|
181
|
-
when Prism::IfNode, Prism::UnlessNode
|
|
182
|
-
unreachable = unreachable_branch_diagnostic(path, node, scope_index)
|
|
183
|
-
diagnostics << unreachable if unreachable
|
|
184
|
-
end
|
|
185
|
-
end
|
|
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
|
|
186
179
|
diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
|
|
187
|
-
|
|
188
|
-
diagnostics.concat(
|
|
189
|
-
diagnostics.concat(
|
|
190
|
-
diagnostics.concat(
|
|
191
|
-
diagnostics.concat(dead_assignment_diagnostics(path, 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))
|
|
192
184
|
filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
|
|
193
185
|
end
|
|
194
186
|
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
209
273
|
end
|
|
210
274
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
215
281
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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)
|
|
290
|
+
diagnostics = []
|
|
291
|
+
Source::NodeWalker.each(root) do |node|
|
|
292
|
+
diagnostics.concat(main_pass_node_diagnostics(path, node, scope_index))
|
|
293
|
+
end
|
|
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)
|
|
219
310
|
end
|
|
220
311
|
|
|
221
312
|
def call_node_diagnostics(path, node, scope_index)
|
|
@@ -252,8 +343,8 @@ module Rigor
|
|
|
252
343
|
# Class-level ivars (`@x = 1` outside any def, in the
|
|
253
344
|
# class body) are also skipped — they're a separate
|
|
254
345
|
# surface (`Module#@var`) the engine doesn't yet model.
|
|
255
|
-
def ivar_write_mismatch_diagnostics(path,
|
|
256
|
-
|
|
346
|
+
def ivar_write_mismatch_diagnostics(path, ivar_writes)
|
|
347
|
+
ivar_writes.flat_map do |class_name, writes_by_ivar|
|
|
257
348
|
writes_by_ivar.flat_map do |ivar_name, writes|
|
|
258
349
|
ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
|
|
259
350
|
end
|
|
@@ -266,8 +357,8 @@ module Rigor
|
|
|
266
357
|
# read in the same body. The
|
|
267
358
|
# `Analysis::CheckRules::DeadAssignmentCollector` describes
|
|
268
359
|
# the conservative envelope.
|
|
269
|
-
def dead_assignment_diagnostics(path,
|
|
270
|
-
|
|
360
|
+
def dead_assignment_diagnostics(path, dead_assignments)
|
|
361
|
+
dead_assignments.map do |result|
|
|
271
362
|
build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
|
|
272
363
|
end
|
|
273
364
|
end
|
|
@@ -421,7 +512,18 @@ module Rigor
|
|
|
421
512
|
scope = scope_index[call_node]
|
|
422
513
|
return nil if scope.nil?
|
|
423
514
|
|
|
424
|
-
|
|
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)
|
|
425
527
|
class_name = concrete_class_name(receiver_type)
|
|
426
528
|
return nil if class_name.nil?
|
|
427
529
|
|
|
@@ -742,7 +844,7 @@ module Rigor
|
|
|
742
844
|
# by `undefined_method_diagnostic`; it returns nil
|
|
743
845
|
# when the call's receiver / RBS coverage / call shape
|
|
744
846
|
# disqualifies the rule.
|
|
745
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
847
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
746
848
|
def wrong_arity_diagnostic(path, call_node, scope_index)
|
|
747
849
|
return nil if call_node.receiver.nil?
|
|
748
850
|
return nil unless plain_positional_call?(call_node)
|
|
@@ -755,6 +857,15 @@ module Rigor
|
|
|
755
857
|
return nil if class_name.nil?
|
|
756
858
|
|
|
757
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)
|
|
758
869
|
return nil if scope.discovered_method?(class_name, call_node.name, kind)
|
|
759
870
|
|
|
760
871
|
return nil unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
|
|
@@ -772,7 +883,25 @@ module Rigor
|
|
|
772
883
|
|
|
773
884
|
build_arity_diagnostic(path, call_node, class_name, min, max, actual)
|
|
774
885
|
end
|
|
775
|
-
# 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
|
|
776
905
|
|
|
777
906
|
def plain_positional_call?(call_node)
|
|
778
907
|
arguments = call_node.arguments
|
|
@@ -864,6 +993,17 @@ module Rigor
|
|
|
864
993
|
scope = scope_index[call_node]
|
|
865
994
|
return nil if scope.nil?
|
|
866
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
|
+
|
|
867
1007
|
receiver_type = scope.type_of(call_node.receiver)
|
|
868
1008
|
return nil unless receiver_type.is_a?(Type::Union)
|
|
869
1009
|
|
|
@@ -883,6 +1023,21 @@ module Rigor
|
|
|
883
1023
|
union.members.any? { |member| nil_member?(member) }
|
|
884
1024
|
end
|
|
885
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
|
+
|
|
886
1041
|
def nil_member?(member)
|
|
887
1042
|
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
888
1043
|
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
@@ -1048,7 +1203,8 @@ module Rigor
|
|
|
1048
1203
|
rule: RULE_NIL_RECEIVER,
|
|
1049
1204
|
path: path,
|
|
1050
1205
|
message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
|
|
1051
|
-
severity: :error
|
|
1206
|
+
severity: :error,
|
|
1207
|
+
method_name: call_node.name.to_s
|
|
1052
1208
|
)
|
|
1053
1209
|
end
|
|
1054
1210
|
|
|
@@ -1236,7 +1392,9 @@ module Rigor
|
|
|
1236
1392
|
rule: RULE_VISIBILITY_MISMATCH,
|
|
1237
1393
|
path: path,
|
|
1238
1394
|
message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
|
|
1239
|
-
severity: :error
|
|
1395
|
+
severity: :error,
|
|
1396
|
+
receiver_type: receiver_type.class_name,
|
|
1397
|
+
method_name: call_node.name.to_s
|
|
1240
1398
|
)
|
|
1241
1399
|
end
|
|
1242
1400
|
|
|
@@ -1435,12 +1593,49 @@ module Rigor
|
|
|
1435
1593
|
arg_type = scope.type_of(arg)
|
|
1436
1594
|
next if arg_type.is_a?(Type::Dynamic) || arg_type.is_a?(Type::Top)
|
|
1437
1595
|
|
|
1438
|
-
|
|
1439
|
-
|
|
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 }
|
|
1440
1599
|
end
|
|
1441
1600
|
nil
|
|
1442
1601
|
end
|
|
1443
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
|
+
|
|
1444
1639
|
def argument_check_eligible?(function)
|
|
1445
1640
|
# See `arity_eligible?`: `UntypedFunction` lacks
|
|
1446
1641
|
# the per-arity accessors. Treat it as ineligible
|
|
@@ -1471,7 +1666,9 @@ module Rigor
|
|
|
1471
1666
|
rule: RULE_ARGUMENT_TYPE,
|
|
1472
1667
|
path: path,
|
|
1473
1668
|
message: message,
|
|
1474
|
-
severity: :error
|
|
1669
|
+
severity: :error,
|
|
1670
|
+
receiver_type: class_name,
|
|
1671
|
+
method_name: call_node.name.to_s
|
|
1475
1672
|
)
|
|
1476
1673
|
end
|
|
1477
1674
|
|
|
@@ -1484,7 +1681,9 @@ module Rigor
|
|
|
1484
1681
|
rule: RULE_WRONG_ARITY,
|
|
1485
1682
|
path: path,
|
|
1486
1683
|
message: message,
|
|
1487
|
-
severity: :error
|
|
1684
|
+
severity: :error,
|
|
1685
|
+
receiver_type: class_name,
|
|
1686
|
+
method_name: call_node.name.to_s
|
|
1488
1687
|
)
|
|
1489
1688
|
end
|
|
1490
1689
|
|
|
@@ -1657,7 +1856,8 @@ module Rigor
|
|
|
1657
1856
|
path: path,
|
|
1658
1857
|
message: "return-type mismatch on `#{def_node.name}': " \
|
|
1659
1858
|
"declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
|
|
1660
|
-
severity: severity
|
|
1859
|
+
severity: severity,
|
|
1860
|
+
method_name: def_node.name.to_s
|
|
1661
1861
|
)
|
|
1662
1862
|
end
|
|
1663
1863
|
|
|
@@ -1809,7 +2009,8 @@ module Rigor
|
|
|
1809
2009
|
message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
|
|
1810
2010
|
"#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
|
|
1811
2011
|
"breaks substitutability",
|
|
1812
|
-
severity: :warning
|
|
2012
|
+
severity: :warning,
|
|
2013
|
+
method_name: def_node.name.to_s
|
|
1813
2014
|
)
|
|
1814
2015
|
end
|
|
1815
2016
|
|
|
@@ -1902,7 +2103,8 @@ module Rigor
|
|
|
1902
2103
|
message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
|
|
1903
2104
|
"to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
|
|
1904
2105
|
"breaks substitutability",
|
|
1905
|
-
severity: :warning
|
|
2106
|
+
severity: :warning,
|
|
2107
|
+
method_name: def_node.name.to_s
|
|
1906
2108
|
)
|
|
1907
2109
|
end
|
|
1908
2110
|
|
|
@@ -2006,7 +2208,8 @@ module Rigor
|
|
|
2006
2208
|
message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
|
|
2007
2209
|
"#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
|
|
2008
2210
|
"(overrides #{parent_class}##{def_node.name}); breaks substitutability",
|
|
2009
|
-
severity: :warning
|
|
2211
|
+
severity: :warning,
|
|
2212
|
+
method_name: def_node.name.to_s
|
|
2010
2213
|
)
|
|
2011
2214
|
end
|
|
2012
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
|
|
@@ -529,7 +529,8 @@ module Rigor
|
|
|
529
529
|
rule: diagnostic.rule,
|
|
530
530
|
authored_severity: diagnostic.severity,
|
|
531
531
|
profile: @configuration.severity_profile,
|
|
532
|
-
overrides: @configuration.severity_overrides
|
|
532
|
+
overrides: @configuration.severity_overrides,
|
|
533
|
+
bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
|
|
533
534
|
)
|
|
534
535
|
return nil if resolved == :off
|
|
535
536
|
return diagnostic if resolved == diagnostic.severity
|
|
@@ -38,6 +38,7 @@ module Rigor
|
|
|
38
38
|
:pre_eval_diagnostics_from_scanner,
|
|
39
39
|
:discovered_classes,
|
|
40
40
|
:discovered_def_nodes,
|
|
41
|
+
:discovered_singleton_def_nodes,
|
|
41
42
|
:discovered_def_sources,
|
|
42
43
|
:discovered_superclasses,
|
|
43
44
|
:discovered_includes,
|
|
@@ -64,7 +65,7 @@ module Rigor
|
|
|
64
65
|
# results bundled in {Result} in the order the downstream `#run`
|
|
65
66
|
# body expects. Extracted so `#prepare_project_scan` and the
|
|
66
67
|
# prebuilt-less `#run` path share one implementation.
|
|
67
|
-
def run(expansion:) # rubocop:disable Metrics/MethodLength
|
|
68
|
+
def run(expansion:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
68
69
|
plugin_registry = load_plugins
|
|
69
70
|
dependency_source_index = DependencySourceInference::Builder.build(@configuration.dependencies)
|
|
70
71
|
# ADR-18 slice 3 — plugin prepare MUST run before the
|
|
@@ -131,6 +132,7 @@ module Rigor
|
|
|
131
132
|
pre_eval_diagnostics_from_scanner: pre_eval_diagnostics_from_scanner,
|
|
132
133
|
discovered_classes: discovered_classes,
|
|
133
134
|
discovered_def_nodes: def_index.fetch(:def_nodes),
|
|
135
|
+
discovered_singleton_def_nodes: def_index.fetch(:singleton_def_nodes),
|
|
134
136
|
discovered_def_sources: def_index.fetch(:def_sources),
|
|
135
137
|
discovered_superclasses: def_index.fetch(:superclasses),
|
|
136
138
|
discovered_includes: def_index.fetch(:includes),
|
|
@@ -173,6 +175,7 @@ module Rigor
|
|
|
173
175
|
pre_eval_diagnostics_from_scanner: scan.pre_eval_diagnostics,
|
|
174
176
|
discovered_classes: nil,
|
|
175
177
|
discovered_def_nodes: nil,
|
|
178
|
+
discovered_singleton_def_nodes: nil,
|
|
176
179
|
discovered_def_sources: nil,
|
|
177
180
|
discovered_superclasses: nil,
|
|
178
181
|
discovered_includes: nil,
|