rigortype 0.3.2 → 0.3.3
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 +1 -1
- data/docs/manual/04-diagnostics.md +8 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +56 -13
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/scope_indexer.rb +119 -28
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddd6ef9252340b3eb225c6f897e877b77805c4debc2444af0a6fa32db9aebea7
|
|
4
|
+
data.tar.gz: 8cb4ddc14efbf6aa1c6bc685e0135b3ce031f56cedb31429e1625272c23b38ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7be72cdb0e5c5a7e5a417291d44fdbfef44628dda68597b2610c1865efb95adf37d492f7fce505965b5e394e9a685504917bcb7aaaa756091086d9217676dbba
|
|
7
|
+
data.tar.gz: 53e6b313960d0a3606be6925dafe0b71850850fcaff78b2f1015d3c4bd2ff97af452e0eaef70da9d4e14f9094e890d72fbc4da77e5508cf4c51ad6650fb680ea
|
data/README.md
CHANGED
|
@@ -231,7 +231,7 @@ rigor docs --list # list every bundled page
|
|
|
231
231
|
|
|
232
232
|
## Status
|
|
233
233
|
|
|
234
|
-
Current release: **`v0.3.
|
|
234
|
+
Current release: **`v0.3.3`** (2026-08-09) — on the
|
|
235
235
|
`0.3.x` line, continuing the evaluation era opened by `v0.2.0`, the first
|
|
236
236
|
publicly-announced (general / evaluation) release. The line publishes
|
|
237
237
|
an enumerated [compatibility surface](docs/compatibility.md) as a
|
|
@@ -225,6 +225,14 @@ on the line the diagnostic points at; there is no
|
|
|
225
225
|
`disable-block` form, so an expression spread over several
|
|
226
226
|
lines needs the comment on each line that fires.
|
|
227
227
|
|
|
228
|
+
The marker has to be the **first thing in the comment** — a
|
|
229
|
+
whole-line `# rigor:disable …` or a trailing
|
|
230
|
+
`expr # rigor:disable …`. A comment that merely quotes the
|
|
231
|
+
syntax, as this manual does throughout, is ordinary prose: it
|
|
232
|
+
suppresses nothing and warns about nothing. The same goes for a
|
|
233
|
+
doubled `## rigor:disable …` and for a marker written inside an
|
|
234
|
+
`=begin` / `=end` block — neither activates.
|
|
235
|
+
|
|
228
236
|
A marker that cannot work is flagged rather than silently
|
|
229
237
|
ignored: a token that names no known rule (a typo like
|
|
230
238
|
`call.undefined-metod`) fires
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "../../source/node_children"
|
|
6
|
+
require_relative "../../inference/optimistic_origin"
|
|
6
7
|
require_relative "inferred_param_guard"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
@@ -108,6 +109,13 @@ module Rigor
|
|
|
108
109
|
# the precision-additive contract.
|
|
109
110
|
return if InferredParamGuard.rooted?(predicate, scope)
|
|
110
111
|
|
|
112
|
+
# Issue #313 — a predicate whose constancy rests on an optimistically nil-free carrier is a bet, not
|
|
113
|
+
# proof: `MAP[key]` omits `nil` because pessimising the defaulted-Hash idiom costs more false
|
|
114
|
+
# positives than the miss it would model, so `MAP[key].nil?` folding to `false` says nothing about
|
|
115
|
+
# whether the key was there. The spec forbids this rule concluding from such a value; the derivation
|
|
116
|
+
# is what carries the mark across `.nil?`, `!` and `&&` / `||` to the predicate the rule reads.
|
|
117
|
+
return unless Inference::OptimisticOrigin.resolve(predicate, scope).nil?
|
|
118
|
+
|
|
111
119
|
predicate_type = scope.type_of(predicate)
|
|
112
120
|
return unless predicate_type.is_a?(Type::Constant)
|
|
113
121
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Analysis
|
|
7
|
+
module CheckRules
|
|
8
|
+
# ADR-58 WD1 — the shared "is this value's optionality declaration-sourced?" predicate.
|
|
9
|
+
#
|
|
10
|
+
# A `nil` whose only provenance is a declaration (the class-ivar index seed of a ctor `@x = nil`, a
|
|
11
|
+
# non-definitely-assigned ivar read) is real type information but not diagnostic fuel: the working
|
|
12
|
+
# program's cross-method invariant is assumed per the robustness principle ([ADR-5]). Every negative rule
|
|
13
|
+
# that would fire *because of* such a nil must therefore consult the same provenance question, and get the
|
|
14
|
+
# same answer.
|
|
15
|
+
#
|
|
16
|
+
# Before issue #324 each rule spelled the lookup itself, and they drifted: `call.possible-nil-receiver`
|
|
17
|
+
# read the `:local` mark (so `r = @right; r.key` was excused — ADR-58's own motivating shape), while both
|
|
18
|
+
# `call.argument-type-mismatch` gates opened by requiring a literal `Prism::InstanceVariableReadNode`, so
|
|
19
|
+
# `c = @count; sink.take_int(c)` fired on the same value with the same provenance. This module is that one
|
|
20
|
+
# question, asked in one place.
|
|
21
|
+
#
|
|
22
|
+
# **The mark is deliberately NOT transitive.** `Scope#with_declaration_sourced_local`
|
|
23
|
+
# ({Inference::StatementEvaluator#eval_local_write}) stamps `:local` only when the RHS is a *pure read of a
|
|
24
|
+
# currently declaration-sourced ivar*, so a second hop (`c = @count; d = c`) and an `||=` rewrite land on
|
|
25
|
+
# the plain `with_local` path and carry no mark.
|
|
26
|
+
#
|
|
27
|
+
# A branch join is NOT one of those cases, contrary to what this comment claimed when it landed: both
|
|
28
|
+
# arms of `if c then r = @count else r = @count end` stamp `(:local, :r)`, and `join_declaration_sourced`
|
|
29
|
+
# INTERSECTS, so the mark survives. Only an *asymmetric* join drops it, and by that intersection rather
|
|
30
|
+
# than by reaching `with_local`. The normative statement — establishment, drop, join and the full list of
|
|
31
|
+
# unmarked shapes, each verified against the implementation — is
|
|
32
|
+
# `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)"; keep this
|
|
33
|
+
# comment subordinate to it.
|
|
34
|
+
#
|
|
35
|
+
# So `marked?` matches exactly the two node shapes the scope actually models and invents no propagation
|
|
36
|
+
# of its own; anything flow-live keeps firing.
|
|
37
|
+
module DeclarationSourcedGuard
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# True when `node` is a direct read of a binding whose optionality is purely declaration-sourced. Any
|
|
41
|
+
# flow-live touch (a method-local `@x = nil` write, a failed-guard narrowing, a rebinding local write)
|
|
42
|
+
# drops the mark upstream, so this returns false there and the caller fires exactly as before.
|
|
43
|
+
def marked?(node, scope)
|
|
44
|
+
case node
|
|
45
|
+
when Prism::InstanceVariableReadNode then scope.declaration_sourced?(:ivar, node.name)
|
|
46
|
+
when Prism::LocalVariableReadNode then scope.declaration_sourced?(:local, node.name)
|
|
47
|
+
else false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -145,6 +145,13 @@ module Rigor
|
|
|
145
145
|
return unless node.is_a?(Prism::Node)
|
|
146
146
|
|
|
147
147
|
dispatch(node, hooks, context)
|
|
148
|
+
# Issue #318 — `defined?`'s operand is never evaluated (the runtime inspects it statically), so
|
|
149
|
+
# nothing under a `DefinedNode` is reachable, evaluated code. Dispatching the DefinedNode itself
|
|
150
|
+
# is fine (a collector may care that it exists); descending into `#value` would feed collectors
|
|
151
|
+
# source that can never execute, e.g. `undefined_method_diagnostic` flagging a call inside
|
|
152
|
+
# `defined? @x && @x.method_call`.
|
|
153
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
154
|
+
|
|
148
155
|
child_context = descend(node, context)
|
|
149
156
|
node.rigor_each_child { |child| walk(child, hooks, child_context) }
|
|
150
157
|
end
|
|
@@ -5,11 +5,13 @@ require "prism"
|
|
|
5
5
|
require_relative "../reflection"
|
|
6
6
|
require_relative "../source/node_walker"
|
|
7
7
|
require_relative "../source/constant_path"
|
|
8
|
+
require_relative "../inference/singleton_object_constant"
|
|
8
9
|
require_relative "../type"
|
|
9
10
|
require_relative "diagnostic"
|
|
10
11
|
require_relative "dependency_recorder"
|
|
11
12
|
require_relative "check_rules/rule_ids"
|
|
12
13
|
require_relative "check_rules/inferred_param_guard"
|
|
14
|
+
require_relative "check_rules/declaration_sourced_guard"
|
|
13
15
|
require_relative "check_rules/rule_walk"
|
|
14
16
|
require_relative "check_rules/always_truthy_condition_collector"
|
|
15
17
|
require_relative "check_rules/unreachable_clause_collector"
|
|
@@ -424,6 +426,12 @@ module Rigor
|
|
|
424
426
|
# only. `# rigor:disable all` on a line suppresses
|
|
425
427
|
# every rule on that line.
|
|
426
428
|
#
|
|
429
|
+
# Both in-source forms are recognised only when the
|
|
430
|
+
# marker is the FIRST thing in the comment, so a
|
|
431
|
+
# directive quoted inside ordinary prose — as the bullets
|
|
432
|
+
# above quote it — is not a directive. See the pattern
|
|
433
|
+
# constants below.
|
|
434
|
+
#
|
|
427
435
|
# Diagnostics with `rule == nil` (parse errors, path
|
|
428
436
|
# errors, internal analyzer errors) are NEVER
|
|
429
437
|
# suppressed — they represent failures the user cannot
|
|
@@ -443,17 +451,26 @@ module Rigor
|
|
|
443
451
|
end
|
|
444
452
|
end
|
|
445
453
|
|
|
446
|
-
|
|
454
|
+
# Every suppression-recognition pattern below is anchored with `\A` against the COMMENT SLICE —
|
|
455
|
+
# Prism hands us a slice that starts at the `#`, so `\A#` means "the marker is the first thing in
|
|
456
|
+
# the comment". A real directive always has that shape, in both the whole-line form and the
|
|
457
|
+
# trailing `code # rigor:disable <rule>` form; prose that merely quotes a directive
|
|
458
|
+
# (`... like `# rigor:disable-file all` ...`) has text before the inner `#` and no longer
|
|
459
|
+
# activates anything. Two consequences worth naming: a doc-tool `##` comment never activates (the
|
|
460
|
+
# second `#` is neither whitespace nor the marker word), and an `=begin`/`=end` block comment
|
|
461
|
+
# never activates either (its slice starts at `=begin`). Unanchored, these patterns silently
|
|
462
|
+
# file-suppressed this very file — see issue #306.
|
|
463
|
+
LINE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
|
|
447
464
|
private_constant :LINE_SUPPRESSION_PATTERN
|
|
448
465
|
|
|
449
|
-
FILE_SUPPRESSION_PATTERN =
|
|
466
|
+
FILE_SUPPRESSION_PATTERN = /\A#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
|
|
450
467
|
private_constant :FILE_SUPPRESSION_PATTERN
|
|
451
468
|
|
|
452
469
|
# A `rigor:disable[-file]` marker word regardless of whether any rule tokens follow. Used only by the
|
|
453
470
|
# `suppression.empty` detection — the two suppression patterns above require at least one token
|
|
454
471
|
# character, so a bare `# rigor:disable` never reaches them. The lookahead keeps
|
|
455
472
|
# `rigor:disable-something-else` from counting as a marker.
|
|
456
|
-
BARE_SUPPRESSION_MARKER =
|
|
473
|
+
BARE_SUPPRESSION_MARKER = /\A#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
|
|
457
474
|
private_constant :BARE_SUPPRESSION_MARKER
|
|
458
475
|
|
|
459
476
|
# A `rigor:` marker word that is NOT part of Rigor's suppression grammar but reads like an attempted
|
|
@@ -463,7 +480,7 @@ module Rigor
|
|
|
463
480
|
# surveillance they silently suppress nothing. Matches `disable-<suffix>` for any suffix other than
|
|
464
481
|
# `file`, and `enable` with or without a suffix.
|
|
465
482
|
UNKNOWN_SUPPRESSION_MARKER =
|
|
466
|
-
|
|
483
|
+
/\A#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
|
|
467
484
|
private_constant :UNKNOWN_SUPPRESSION_MARKER
|
|
468
485
|
|
|
469
486
|
# @return [Array<(Hash{Integer => Set}, Set)>] pair of
|
|
@@ -527,9 +544,10 @@ module Rigor
|
|
|
527
544
|
end
|
|
528
545
|
|
|
529
546
|
# A comment carrying the marker word but not the token-bearing suppression grammar. A remainder of
|
|
530
|
-
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else
|
|
531
|
-
#
|
|
532
|
-
#
|
|
547
|
+
# nothing but whitespace / commas is a genuinely empty marker (`# rigor:disable`); anything else is
|
|
548
|
+
# left alone as an ordinary comment, matching the parse path, which never treats it as a
|
|
549
|
+
# suppression either. Prose that merely quotes a marker is already excluded a step earlier by the
|
|
550
|
+
# `\A` anchor, since the quotation is not at the start of the comment.
|
|
533
551
|
def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
|
|
534
552
|
bare = BARE_SUPPRESSION_MARKER.match(source)
|
|
535
553
|
if bare
|
|
@@ -545,8 +563,9 @@ module Rigor
|
|
|
545
563
|
|
|
546
564
|
# `# rigor:disable-next-line <rule>` / `# rigor:enable <rule>` — a marker word Rigor's grammar does
|
|
547
565
|
# not recognise but that reads as an attempted suppression (the RuboCop reflex). Fires only when the
|
|
548
|
-
# remainder is empty or looks like a rule list, so prose mentioning
|
|
549
|
-
# an ordinary comment — the same escape the empty-marker detection
|
|
566
|
+
# marker opens the comment and the remainder is empty or looks like a rule list, so prose mentioning
|
|
567
|
+
# the spelling in backticks stays an ordinary comment — the same escape the empty-marker detection
|
|
568
|
+
# observes.
|
|
550
569
|
def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
|
|
551
570
|
unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
|
|
552
571
|
return if unknown.nil?
|
|
@@ -656,6 +675,13 @@ module Rigor
|
|
|
656
675
|
# non-nil constituent (which, for a cross-file project def, would
|
|
657
676
|
# be a working-code false positive).
|
|
658
677
|
receiver_type = safe_navigation_receiver(call_node, scope)
|
|
678
|
+
|
|
679
|
+
# #320 — the private-singleton-object idiom (`class << Merger = Object.new`). The body's methods
|
|
680
|
+
# are recorded on the constant's own name, but the receiver reads back as `Object`, so the
|
|
681
|
+
# class-keyed probes below cannot see them. Recover the name from the receiver syntax. Scoped to
|
|
682
|
+
# the recorded name only: `Merger.nope` still fires.
|
|
683
|
+
return nil if Inference::SingletonObjectConstant.recorded?(call_node, receiver_type, call_node.name, scope)
|
|
684
|
+
|
|
659
685
|
class_name = concrete_class_name(receiver_type)
|
|
660
686
|
# A union receiver has no single concrete class. The scalar path
|
|
661
687
|
# below cannot reason about it, but the call is still definitely
|
|
@@ -1255,8 +1281,12 @@ module Rigor
|
|
|
1255
1281
|
# principle. The nil stays in the displayed type; only its use as
|
|
1256
1282
|
# diagnostic fuel is withheld. Any flow-live touch (method-local
|
|
1257
1283
|
# nil write, failed-guard narrowing) drops the mark upstream, so
|
|
1258
|
-
# flow-observed nil keeps firing exactly as before.
|
|
1259
|
-
|
|
1284
|
+
# flow-observed nil keeps firing exactly as before. The receiver
|
|
1285
|
+
# is already narrowed to a local read above, so this asks
|
|
1286
|
+
# {DeclarationSourcedGuard} exactly what it asked before — but it
|
|
1287
|
+
# now asks it through the predicate the argument-type gates share
|
|
1288
|
+
# (issue #324), which is what keeps the two rules from drifting.
|
|
1289
|
+
return nil if DeclarationSourcedGuard.marked?(call_node.receiver, scope)
|
|
1260
1290
|
|
|
1261
1291
|
# ADR-67 WD6b — an inferred-parameter receiver's type (incl. any nil constituent unioned in from a
|
|
1262
1292
|
# nil call site) is an open-call-site lower bound; a possible-nil firing against it is an FP by
|
|
@@ -2383,13 +2413,16 @@ module Rigor
|
|
|
2383
2413
|
params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
|
|
2384
2414
|
end
|
|
2385
2415
|
|
|
2386
|
-
# ADR-58 parity for the nil channel: a declaration-sourced
|
|
2387
|
-
#
|
|
2388
|
-
#
|
|
2389
|
-
#
|
|
2390
|
-
#
|
|
2416
|
+
# ADR-58 parity for the nil channel: a declaration-sourced read that
|
|
2417
|
+
# types as nil is the same not-diagnostic-fuel case the union path
|
|
2418
|
+
# gates in {#declaration_sourced_nil_only_mismatch?}; suppress it here
|
|
2419
|
+
# too so a ctor-seeded `@x = nil` read passed as an argument does not
|
|
2420
|
+
# fire on a working program's cross-method invariant. Issue #324 —
|
|
2421
|
+
# delegates to {DeclarationSourcedGuard}, so a local copy of the ivar
|
|
2422
|
+
# (`c = @count`) is excused on the same terms `possible-nil-receiver`
|
|
2423
|
+
# already excused it.
|
|
2391
2424
|
def declaration_sourced_nil_argument?(arg, scope)
|
|
2392
|
-
|
|
2425
|
+
DeclarationSourcedGuard.marked?(arg, scope)
|
|
2393
2426
|
end
|
|
2394
2427
|
|
|
2395
2428
|
def first_argument_mismatch(method_type, call_node, scope, param_overrides)
|
|
@@ -2452,14 +2485,15 @@ module Rigor
|
|
|
2452
2485
|
!declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2453
2486
|
end
|
|
2454
2487
|
|
|
2455
|
-
# True when `arg` is a declaration-sourced
|
|
2488
|
+
# True when `arg` is a declaration-sourced read whose rejection is
|
|
2456
2489
|
# caused solely by its nil constituent: stripping nil from the argument
|
|
2457
2490
|
# type yields a type the parameter accepts (gradual mode). Mirrors the
|
|
2458
|
-
# `possible-nil-receiver` WD1 gate
|
|
2459
|
-
#
|
|
2491
|
+
# `possible-nil-receiver` WD1 gate — since issue #324 through the very
|
|
2492
|
+
# same {DeclarationSourcedGuard} predicate, so the ivar read and its
|
|
2493
|
+
# local copy (`c = @count`) are one case rather than two spellings that
|
|
2494
|
+
# can drift apart again.
|
|
2460
2495
|
def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
|
|
2461
|
-
return false unless
|
|
2462
|
-
return false unless scope.declaration_sourced?(:ivar, arg.name)
|
|
2496
|
+
return false unless DeclarationSourcedGuard.marked?(arg, scope)
|
|
2463
2497
|
return false unless arg_type.is_a?(Type::Union)
|
|
2464
2498
|
return false unless union_contains_nil?(arg_type)
|
|
2465
2499
|
|
|
@@ -2720,6 +2754,12 @@ module Rigor
|
|
|
2720
2754
|
|
|
2721
2755
|
# Returns true when `override_visibility` is strictly more restrictive than `parent_visibility`
|
|
2722
2756
|
# under the public > protected > private ordering.
|
|
2757
|
+
#
|
|
2758
|
+
# The nil guard below is defence, not dead code. `VISIBILITY_RANK` is a closed literal hash read
|
|
2759
|
+
# with a dynamic key, so a symbol outside the table reads as nil at runtime. The engine folds that
|
|
2760
|
+
# read to the nil-free value union `0 | 1 | 2`, which `internal-spec/inference-engine.md` declares
|
|
2761
|
+
# OPTIMISTIC rather than proof — and since issue #313 that mark survives the `.nil?` fold and the
|
|
2762
|
+
# `||` composition, so no suppression directive is needed here.
|
|
2723
2763
|
def visibility_reduced?(parent_visibility, override_visibility)
|
|
2724
2764
|
parent_rank = VISIBILITY_RANK[parent_visibility]
|
|
2725
2765
|
override_rank = VISIBILITY_RANK[override_visibility]
|
|
@@ -660,6 +660,8 @@ module Rigor
|
|
|
660
660
|
"The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
|
|
661
661
|
"The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
|
|
662
662
|
"be enumerated statically and under-warning is the FP-safe direction.",
|
|
663
|
+
"The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
|
|
664
|
+
"`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
|
|
663
665
|
"The comment merely mentions the marker followed by non-token text (documentation prose " \
|
|
664
666
|
"like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
|
|
665
667
|
],
|
|
@@ -684,6 +686,7 @@ module Rigor
|
|
|
684
686
|
does_not_fire_when: [
|
|
685
687
|
"At least one token follows the marker (each token is then checked by " \
|
|
686
688
|
"`suppression.unknown-rule` instead).",
|
|
689
|
+
"The marker does not open the comment (documentation prose quoting the syntax).",
|
|
687
690
|
"Non-token text follows the marker (documentation prose mentioning the syntax)."
|
|
688
691
|
],
|
|
689
692
|
suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
|
|
@@ -730,7 +733,7 @@ module Rigor
|
|
|
730
733
|
summary: "A comment uses a suppression marker Rigor does not recognise " \
|
|
731
734
|
"(`rigor:disable-next-line`, `rigor:enable`, ...).",
|
|
732
735
|
fires_when: [
|
|
733
|
-
"A comment
|
|
736
|
+
"A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
|
|
734
737
|
"`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
|
|
735
738
|
"<rule>` — followed by nothing or a rule-list-shaped remainder.",
|
|
736
739
|
"Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
|
|
@@ -740,6 +743,7 @@ module Rigor
|
|
|
740
743
|
does_not_fire_when: [
|
|
741
744
|
"The marker is one of the two recognised forms (their tokens are then checked by " \
|
|
742
745
|
"`suppression.unknown-rule` / `suppression.empty` instead).",
|
|
746
|
+
"The marker does not open the comment (documentation prose quoting the spelling).",
|
|
743
747
|
"Non-token text follows the marker (documentation prose mentioning the spelling)."
|
|
744
748
|
],
|
|
745
749
|
suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
|
|
@@ -35,7 +35,8 @@ module Rigor
|
|
|
35
35
|
Diagnostic.new(
|
|
36
36
|
path: diagnostic.path, line: diagnostic.line, column: diagnostic.column,
|
|
37
37
|
message: diagnostic.message, severity: resolved, rule: diagnostic.rule,
|
|
38
|
-
source_family: diagnostic.source_family
|
|
38
|
+
source_family: diagnostic.source_family, receiver_type: diagnostic.receiver_type,
|
|
39
|
+
method_name: diagnostic.method_name, project_definition_site: diagnostic.project_definition_site
|
|
39
40
|
)
|
|
40
41
|
end
|
|
41
42
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../type/anonymous_class_name"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
# #319 — identity for the class a `Class.new do ... end` / `Module.new do ... end` creates away from
|
|
10
|
+
# constant-write position.
|
|
11
|
+
#
|
|
12
|
+
# Ruby evaluates such a block with `self` bound to the freshly created class (`class_eval` semantics): a
|
|
13
|
+
# `def` inside it defines an instance method on that class and `attr_reader` runs as a class-level macro.
|
|
14
|
+
# `ScopeIndexer` has always walked that body as a class body — but only through its
|
|
15
|
+
# `Prism::ConstantWriteNode` branch, because the constant supplied the name the discovered-method tables
|
|
16
|
+
# are keyed by. At every other position (`klass = Class.new do ... end`, a bare expression, a method
|
|
17
|
+
# return) there was no name, so the body fell through to the enclosing — usually top-level — scope. Two
|
|
18
|
+
# false positives followed on correct code: `attr_reader` became a `call.unresolved-toplevel` warning
|
|
19
|
+
# (`Scope#toplevel?` is `self_type.nil?`), and the class itself typed as the bare `Singleton[Object]`
|
|
20
|
+
# `MethodDispatcher#class_new_lift` hands back, taking `Object`'s zero-arity `new` with it.
|
|
21
|
+
#
|
|
22
|
+
# This module supplies the missing name: a per-call-site synthetic one, spelled so that no Ruby constant
|
|
23
|
+
# path can collide with it in the discovery tables, and stable across the index pass and the dispatch
|
|
24
|
+
# pass because both derive it from the same `(call node, source path)` pair.
|
|
25
|
+
module AnonymousMetaClass
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The class-creating meta calls whose block body is a class body. `Struct.new` / `Data.define` are here
|
|
29
|
+
# because the body semantics are identical — the block is `class_eval`'d on the generated subclass — not
|
|
30
|
+
# because their anonymous *value* is modelled: those keep the `StructClass` / `DataClass` carriers
|
|
31
|
+
# {MethodDispatcher::StructFolding} / {MethodDispatcher::DataFolding} build for them.
|
|
32
|
+
META_NEW_SELECTORS = { Class: :new, Module: :new, Struct: :new, Data: :define }.freeze
|
|
33
|
+
|
|
34
|
+
# The receiver constant name (`:Class`, `:Module`, `:Struct`, `:Data`) of a class-creating meta call that
|
|
35
|
+
# carries a literal block, or nil for anything else. The receiver MUST be the bare constant (or its
|
|
36
|
+
# `::`-rooted spelling) — a call through a variable or another call's return has no statically known
|
|
37
|
+
# identity, exactly as `ScopeIndexer#meta_constant_receiver?` requires.
|
|
38
|
+
#
|
|
39
|
+
# Argument shapes are deliberately NOT inspected: `Class.new(Base) { ... }` and
|
|
40
|
+
# `Data.define(*members) { ... }` evaluate their block as a class body whatever the arguments are, and a
|
|
41
|
+
# narrower gate here would leave the body in top-level scope again.
|
|
42
|
+
def block_form_receiver(node)
|
|
43
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
44
|
+
return nil unless node.block.is_a?(Prism::BlockNode)
|
|
45
|
+
|
|
46
|
+
receiver_name = literal_constant_name(node.receiver)
|
|
47
|
+
return nil if receiver_name.nil?
|
|
48
|
+
return nil unless META_NEW_SELECTORS[receiver_name] == node.name
|
|
49
|
+
|
|
50
|
+
receiver_name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The synthetic class name for `node`'s block body, or nil when `node` is not a recognised block form.
|
|
54
|
+
# {Type::AnonymousClassName} owns the spelling; this decides which call sites earn one and what the key
|
|
55
|
+
# holds.
|
|
56
|
+
#
|
|
57
|
+
# `source_path` is part of the key so two files whose anonymous classes happen to share a line and column
|
|
58
|
+
# do not merge their method sets when the per-file tables are folded into the cross-file seed. It is
|
|
59
|
+
# omitted when the caller has no path (unit-level probes over a bare `Prism` program) — passed through
|
|
60
|
+
# verbatim rather than defaulted, so every pass over the same file derives the same name.
|
|
61
|
+
def name_for(node, source_path = nil)
|
|
62
|
+
receiver_name = block_form_receiver(node)
|
|
63
|
+
return nil if receiver_name.nil?
|
|
64
|
+
|
|
65
|
+
location = node.location
|
|
66
|
+
key = [source_path, location.start_line, location.start_column].compact.join(":")
|
|
67
|
+
Type::AnonymousClassName.build(receiver_name, key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The bare-constant receiver's name (`Class`, `::Class`), or nil for every other receiver shape.
|
|
71
|
+
def literal_constant_name(node)
|
|
72
|
+
case node
|
|
73
|
+
when Prism::ConstantReadNode
|
|
74
|
+
node.name
|
|
75
|
+
when Prism::ConstantPathNode
|
|
76
|
+
node.name if node.parent.nil?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -19,6 +19,8 @@ require_relative "indexed_narrowing"
|
|
|
19
19
|
require_relative "macro_block_self_type"
|
|
20
20
|
require_relative "method_dispatcher"
|
|
21
21
|
require_relative "narrowing"
|
|
22
|
+
require_relative "singleton_object_constant"
|
|
23
|
+
require_relative "optimistic_origin"
|
|
22
24
|
require_relative "struct_fold_safety"
|
|
23
25
|
|
|
24
26
|
module Rigor
|
|
@@ -609,16 +611,11 @@ module Rigor
|
|
|
609
611
|
elide_or_union(node.predicate, else_type, then_type)
|
|
610
612
|
end
|
|
611
613
|
|
|
612
|
-
# Issue #286 — the effective optimistic-nil-free cause of an expression
|
|
613
|
-
#
|
|
614
|
+
# Issue #286 — the effective optimistic-nil-free cause of an expression. {OptimisticOrigin.resolve} owns
|
|
615
|
+
# the judgment, shared verbatim with `StatementEvaluator#optimistic_origin_for` and the
|
|
616
|
+
# `flow.always-truthy-condition` collector.
|
|
614
617
|
def optimistic_origin_for(node)
|
|
615
|
-
|
|
616
|
-
return recorded if recorded
|
|
617
|
-
|
|
618
|
-
case node
|
|
619
|
-
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.optimistic_local(node.name)
|
|
620
|
-
when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.optimistic_ivar(node.name)
|
|
621
|
-
end
|
|
618
|
+
OptimisticOrigin.resolve(node, scope)
|
|
622
619
|
end
|
|
623
620
|
|
|
624
621
|
def if_else_type(subsequent)
|
|
@@ -663,7 +660,7 @@ module Rigor
|
|
|
663
660
|
# fallback is preserved.
|
|
664
661
|
def type_of_and_or(node)
|
|
665
662
|
left_type = type_of(node.left)
|
|
666
|
-
polarity =
|
|
663
|
+
polarity = left_operand_polarity(node.left, left_type)
|
|
667
664
|
return short_circuit_for(node, left_type, polarity) if polarity
|
|
668
665
|
|
|
669
666
|
# The left operand only flows through on the edge that short-circuits: `a || b` yields `a` solely
|
|
@@ -689,6 +686,19 @@ module Rigor
|
|
|
689
686
|
end
|
|
690
687
|
end
|
|
691
688
|
|
|
689
|
+
# Issue #313 — the node-aware wrapper the `&&` / `||` short-circuit reads. The spec's exclusion binds
|
|
690
|
+
# this gate as much as it binds `flow.always-truthy-condition`, and a `Constant`-only gate is not by
|
|
691
|
+
# itself enough to honour it: a literal hash whose values share one type reads as a lone `Constant`
|
|
692
|
+
# (`UNIFORM[key]` → `Constant[1]`), so the gate would judge the left operand of `UNIFORM[key] || key`
|
|
693
|
+
# provably truthy and discard the author's fallback — the counter-example the spec names verbatim.
|
|
694
|
+
# Declining returns the union of both operands, which is what `StatementEvaluator#eval_and_or` produces
|
|
695
|
+
# anyway, so the two `&&` / `||` typers stay in agreement.
|
|
696
|
+
def left_operand_polarity(left_node, left_type)
|
|
697
|
+
return nil unless optimistic_origin_for(left_node).nil?
|
|
698
|
+
|
|
699
|
+
constant_value_polarity(left_type)
|
|
700
|
+
end
|
|
701
|
+
|
|
692
702
|
# Returns `:truthy` / `:falsey` for a `Type::Constant`, nil otherwise. Mirrors
|
|
693
703
|
# `constant_predicate_polarity` but operates on a typed value (already-type-of'd) rather than a Prism
|
|
694
704
|
# node, so the same predicate analysis can be reused in both contexts.
|
|
@@ -1092,8 +1102,12 @@ module Rigor
|
|
|
1092
1102
|
# through `Enumerable#select` / `Object#select` and the caller observes `Array[Elem]` instead of the
|
|
1093
1103
|
# helper's actual return type. The check fires only for `node.receiver.nil?` (true implicit self), so
|
|
1094
1104
|
# explicit-receiver dispatch is unaffected.
|
|
1105
|
+
#
|
|
1106
|
+
# Issue #316 — the lookup goes through the confidence-gated `Scope#bindable_top_level_def_for`, not the
|
|
1107
|
+
# raw table: inside a block whose `self` is unmodelled, a top-level `def` from ANOTHER file is not
|
|
1108
|
+
# evidence about which method the call reaches, so the bind is declined and the call widens.
|
|
1095
1109
|
def try_local_def_dispatch(node, receiver, arg_types)
|
|
1096
|
-
local_def = node.receiver.nil? ? scope.
|
|
1110
|
+
local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
|
|
1097
1111
|
return nil unless local_def
|
|
1098
1112
|
|
|
1099
1113
|
local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
|
|
@@ -1159,7 +1173,7 @@ module Rigor
|
|
|
1159
1173
|
# `module_function` body, re-type that body with the call args bound. Sits after the RBS dispatch
|
|
1160
1174
|
# tier, so foreign / RBS-known singletons (`Math.sqrt`) keep their catalog answer; only
|
|
1161
1175
|
# project-defined singleton methods reach here.
|
|
1162
|
-
singleton_inference =
|
|
1176
|
+
singleton_inference = try_project_singleton_inference(receiver, node, arg_types)
|
|
1163
1177
|
return singleton_inference if singleton_inference
|
|
1164
1178
|
|
|
1165
1179
|
# Dynamic-origin propagation: when the receiver is Dynamic[T] and no positive rule resolves the call,
|
|
@@ -1336,6 +1350,31 @@ module Rigor
|
|
|
1336
1350
|
nil
|
|
1337
1351
|
end
|
|
1338
1352
|
|
|
1353
|
+
# The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
|
|
1354
|
+
# body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
|
|
1355
|
+
# `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
|
|
1356
|
+
# object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
|
|
1357
|
+
def try_project_singleton_inference(receiver, call_node, arg_types)
|
|
1358
|
+
try_singleton_method_inference(receiver, call_node, arg_types) ||
|
|
1359
|
+
try_singleton_object_constant_inference(receiver, call_node, arg_types)
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
# #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
|
|
1363
|
+
# singleton body, re-typing that body with the call's argument types bound. `self` inside the body IS
|
|
1364
|
+
# that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
|
|
1365
|
+
# name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
|
|
1366
|
+
# resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
|
|
1367
|
+
def try_singleton_object_constant_inference(receiver, call_node, arg_types)
|
|
1368
|
+
return nil unless receiver.is_a?(Type::Nominal)
|
|
1369
|
+
|
|
1370
|
+
def_node = SingletonObjectConstant.def_node_for(call_node, receiver, call_node.name, scope)
|
|
1371
|
+
return nil if def_node.nil?
|
|
1372
|
+
|
|
1373
|
+
infer_user_method_return(def_node, receiver, arg_types)
|
|
1374
|
+
rescue StandardError
|
|
1375
|
+
nil
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1339
1378
|
# ADR-24 slice 2 — resolves `method_name` against `class_name`'s own `def`s, then walks the user-class
|
|
1340
1379
|
# ancestor chain: included / prepended modules (transitive) and the superclass chain. RBS-known
|
|
1341
1380
|
# ancestors are NOT walked here — the `MethodDispatcher` RBS tier runs before
|
|
@@ -2444,7 +2483,11 @@ module Rigor
|
|
|
2444
2483
|
case block_arg
|
|
2445
2484
|
when Prism::BlockNode
|
|
2446
2485
|
bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_arg)
|
|
2447
|
-
|
|
2486
|
+
# Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
|
|
2487
|
+
# yielding method's business, so the return-typing pass must see the same unmodelled-self mark.
|
|
2488
|
+
block_scope = bindings.reduce(scope.entering_opaque_block) do |acc, (name, type)|
|
|
2489
|
+
acc.with_local(name, type)
|
|
2490
|
+
end
|
|
2448
2491
|
block_scope = block_scope.with_self_type(narrowed_self_type) if narrowed_self_type
|
|
2449
2492
|
type_block_body(block_arg, block_scope)
|
|
2450
2493
|
when Prism::BlockArgumentNode
|
|
@@ -37,6 +37,27 @@ module Rigor
|
|
|
37
37
|
worker_count > 1 && Process.respond_to?(:fork)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Where a forked child may put process-private scratch files. `nil` on the parent and on the sequential
|
|
41
|
+
# path, meaning "you are not in a child — use `Dir.tmpdir`".
|
|
42
|
+
#
|
|
43
|
+
# A child ends at {.run_worker}'s `exit!`, which skips `at_exit` AND object finalizers, so a `Tempfile`
|
|
44
|
+
# a child creates is never reclaimed by the mechanism that reclaims the parent's (issue #330 —
|
|
45
|
+
# `Protection::ClosureKillOracle`'s per-process mutant file leaked one file per worker per
|
|
46
|
+
# `rigor coverage --protection --mutation` run). Answering with the directory the parent already removes
|
|
47
|
+
# once every child has been collected makes the child's scratch state the parent's problem, which is the
|
|
48
|
+
# only place it can be solved: the names are random, so nothing outside the child could find them.
|
|
49
|
+
#
|
|
50
|
+
# The directory check is the belt to {.run_worker}'s braces: the offer is only worth taking while the
|
|
51
|
+
# parent still has the directory open, and answering `nil` once it is gone degrades to `Dir.tmpdir`
|
|
52
|
+
# instead of raising `Errno::ENOENT` somewhere far from here.
|
|
53
|
+
#
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def child_scratch_dir
|
|
56
|
+
return nil unless @child_scratch_dir && File.directory?(@child_scratch_dir)
|
|
57
|
+
|
|
58
|
+
@child_scratch_dir
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
# Forks `worker_count` children over contiguous slices, waits for each, and returns the Marshal'd
|
|
41
62
|
# per-slice payloads in slice order. A child that exits abnormally has its slice re-run in-process.
|
|
42
63
|
def fork_map(items, worker_count, block)
|
|
@@ -57,18 +78,31 @@ module Rigor
|
|
|
57
78
|
# Child-process body: run the block over the slice, Marshal the result to `out_path`, and `exit!`
|
|
58
79
|
# (skipping `at_exit` / stdio flush — the payload is durable on disk). Any failure exits non-zero so
|
|
59
80
|
# the parent re-runs the slice in-process.
|
|
81
|
+
# The `ensure` is not dead code even though `exit!` never unwinds: {.child_scratch_dir} is module state,
|
|
82
|
+
# and a caller that drives this body in-process with `exit!` stubbed (the deferred-YJIT spec does exactly
|
|
83
|
+
# that — a spy in the parent cannot observe a real child) would otherwise leave the offer standing for
|
|
84
|
+
# the rest of the process, pointing at a directory its `Dir.mktmpdir` block has since removed.
|
|
60
85
|
def run_worker(slice, block, out_path)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
status = begin
|
|
87
|
+
# Re-arm deferred YJIT in the child, exactly as the check fork pool does
|
|
88
|
+
# ({Analysis::Runner::PoolCoordinator#run_fork_worker}). The parent's deadline thread does not
|
|
89
|
+
# survive `fork`, so without this a worker runs its whole slice un-JITted while the parent JITs the
|
|
90
|
+
# tail it no longer runs — the pool was a *pessimization* on long runs (`coverage --protection
|
|
91
|
+
# --mutation lib/rigor/analysis`: 37s sequential against 67s at eight workers). The child picks up
|
|
92
|
+
# what is left of the parent's window rather than a fresh one; {Runtime::Jit.rearm_after_fork}
|
|
93
|
+
# carries the mechanism.
|
|
94
|
+
Runtime::Jit.rearm_after_fork
|
|
95
|
+
# Published before the block runs, so anything it lazily creates for this process alone lands in the
|
|
96
|
+
# parent's tmpdir and dies with it. See {.child_scratch_dir}.
|
|
97
|
+
@child_scratch_dir = File.dirname(out_path)
|
|
98
|
+
File.binwrite(out_path, Marshal.dump(block.call(slice)))
|
|
99
|
+
0
|
|
100
|
+
rescue StandardError
|
|
101
|
+
1
|
|
102
|
+
ensure
|
|
103
|
+
@child_scratch_dir = nil
|
|
104
|
+
end
|
|
105
|
+
exit!(status)
|
|
72
106
|
end
|
|
73
107
|
|
|
74
108
|
# Waits for every child, placing each successful payload at its slice index; a child that exited
|