rigortype 0.3.1 → 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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -28,12 +28,23 @@ on:
28
28
  - the **content** of the source and `.rbs` files that fed it,
29
29
  - the **gems** in play, by name and locked version,
30
30
  - the active **plugins**, by ID and version,
31
- - the relevant **configuration**.
31
+ - the relevant **configuration**,
32
+ - **Rigor itself**, by version.
32
33
 
33
34
  Change any of those and the dependent entries are recomputed
34
35
  automatically. A corrupt or unreadable entry is treated as a
35
36
  miss and overwritten — bad cache state cannot wedge a run.
36
37
 
38
+ Rigor's version identifies its own code only for a released gem.
39
+ If you run Rigor from a git checkout — a working copy you are
40
+ patching, or `gem "rigor", github:` tracking a branch, where two
41
+ commits share one version number — the entries that hold
42
+ analysis results are keyed on the content of Rigor's own source
43
+ as well, so editing the analyzer and re-running recomputes
44
+ instead of replaying the previous answer. That costs one pass
45
+ over Rigor's source per run (about 20 ms); an installed gem
46
+ neither pays it nor needs it.
47
+
37
48
  The cache is also schema-versioned: after a Rigor upgrade that
38
49
  changes the cache format, the stale cache is purged on the
39
50
  first writable run.
@@ -157,10 +168,11 @@ affected files in parallel just as a full run does.
157
168
 
158
169
  The snapshot lives under the cache directory (`.rigor/cache`)
159
170
  and is keyed by a fingerprint of your configuration, your locked
160
- gems, your project's own `sig/` RBS, and the Rigor version.
161
- Change any of those and the snapshot is dropped and the next run
162
- is a full one, so an incremental run can never serve a stale
163
- result. (The fingerprint keys on the analysis *roots* e.g.
171
+ gems, your project's own `sig/` RBS, the Rigor version, and — if
172
+ you run Rigor from a checkout the content of Rigor's own
173
+ source. Change any of those and the snapshot is dropped and the
174
+ next run is a full one, so an incremental run can never serve a
175
+ stale result. (The fingerprint keys on the analysis *roots* — e.g.
164
176
  `["lib"]` — not the expanded file list, so adding or removing a
165
177
  file *under* those roots does **not** drop the snapshot: the
166
178
  incremental session re-analyzes the added files and the
@@ -48,6 +48,17 @@ power. `--threshold=RATIO` turns it into a CI gate (exit `1`
48
48
  below the ratio) and `--format=json` carries the structured
49
49
  fields.
50
50
 
51
+ Some `protected` sites are typed only through call-site
52
+ [parameter inference](03-configuration.md) (`parameter_inference:`
53
+ — an undeclared parameter seeded from what its callers pass)
54
+ rather than a declaration — a lower bound that can only widen, so
55
+ none of Rigor's negative rules fire against it yet
56
+ ([ADR-67](../adr/67-parameter-type-inference.md) WD6b). The text
57
+ report calls these out as "lower-bound-typed" when there are any,
58
+ and the JSON form always carries the count as `lower_bound_typed`.
59
+ It is a sub-count *within* `protected`, never subtracted from it —
60
+ the protected ratio itself is unaffected.
61
+
51
62
  On a large project, `--workers=N` fork-parallelizes the scan
52
63
  (both the parameter-inference pre-pass and the per-file scan),
53
64
  with output byte-identical to a sequential run. The worker count
@@ -74,11 +85,117 @@ real "add a type here" site, surfaced with no guesswork.
74
85
 
75
86
  It runs many analyses, so it defaults to the **git-changed** `.rb`
76
87
  files (pass explicit paths to widen — whole-project is minutes) and
77
- is an opt-in CI deep-dive, not an interactive check. The framing is
88
+ is an opt-in CI deep-dive, not an interactive check. `--workers=N`
89
+ applies here too, and it is the main lever on a wide run: the
90
+ whole-project pre-pass is paid once, then the per-file measurement —
91
+ which is nearly all of the wall time — is fork-mapped across
92
+ workers, byte-identically to a sequential run.
93
+
94
+ The framing is
78
95
  always *effectiveness / where to add a type*, never "your code is
79
96
  broken": a surviving breakage at a `Dynamic` site is a place
80
97
  the type net does not reach.
81
98
 
99
+ ### Re-running it is cheap
100
+
101
+ Each file's measurement is cached and served back while nothing
102
+ that could change it has moved — the file itself, any file it was
103
+ recorded as reading from, the resolved configuration, your `sig/`,
104
+ the gem set, the engine version, `--limit` / `--seed`, and which
105
+ bleeding-edge features you have adopted. Edit one file and that
106
+ file plus the files recorded as reading from it are re-measured;
107
+ the rest are served. A one-line stderr report says what happened,
108
+ so `--format=json` stdout stays clean:
109
+
110
+ ```
111
+ coverage: mutation cache — re-measured 2 file(s), 350 served from cache.
112
+ ```
113
+
114
+ The cache reads the cross-file dependency edges that a
115
+ `rigor check --incremental` run records, so warm one once per
116
+ project:
117
+
118
+ ```sh
119
+ rigor check --incremental # records the edges
120
+ rigor coverage --protection --mutation lib # measures, then caches
121
+ ```
122
+
123
+ Without that snapshot — or under `--no-cache`, or with
124
+ `dependent-closure-kill-oracle` adopted, whose verdicts depend on
125
+ *other* files' diagnostics and so cannot be validated per file —
126
+ every file is re-measured and the stderr line names the reason. A
127
+ file the snapshot does not know about is re-measured rather than
128
+ served: the cache never guesses.
129
+
130
+ ### Which sites Tier 2 measures
131
+
132
+ Tier 2 only mutates a site where Rigor holds a *concrete* receiver
133
+ type — at a `Dynamic` receiver the breakage would survive by
134
+ construction, which measures nothing. Today it decides that from
135
+ the file alone, so a call on a project class declared in a
136
+ **sibling** file (`Post.where`, `Account.find`) reads `Dynamic` and
137
+ the site is left out of the denominator entirely, even though Tier
138
+ 1 counts it.
139
+
140
+ The [`discovery-seeded-mutation-sites`](02-cli-reference.md#rigor-show-bleedingedge)
141
+ bleeding-edge feature closes that gap, giving Tier 2 the same
142
+ cross-file view Tier 1 has — on *both* halves of the measurement,
143
+ site selection and the re-analysis that decides whether a mutation
144
+ was caught, so an admitted site is one a breakage can really trip:
145
+
146
+ ```yaml
147
+ # .rigor.yml
148
+ bleeding_edge:
149
+ - discovery-seeded-mutation-sites
150
+ ```
151
+
152
+ It is **off by default and opt-in on purpose**. Those sites are
153
+ mostly ones the type net does not yet catch, so admitting them
154
+ *lowers* the reported effectiveness ratio on unchanged code — and
155
+ `--threshold=RATIO` exits 1 below a ratio you pin in CI. Adopt it
156
+ when you are ready to re-baseline that number; it is the intended
157
+ default at a future major.
158
+
159
+ ### Where a catch is allowed to land
160
+
161
+ Deciding whether a breakage was caught is a separate question from
162
+ which sites are measured, and it has the same blind spot in the
163
+ opposite direction. Tier 2 re-analyses the mutated file and looks
164
+ at *that file's* diagnostics — so the most valuable catch Rigor
165
+ delivers is scored as a miss. Change what a method returns and the
166
+ error appears in its **callers**, which is exactly the cross-file
167
+ reach the analyzer exists for.
168
+
169
+ The [`dependent-closure-kill-oracle`](02-cli-reference.md#rigor-show-bleedingedge)
170
+ bleeding-edge feature re-analyses the mutated file **and the files
171
+ that depend on it**, and counts the breakage as caught when a new
172
+ diagnostic appears anywhere in that set:
173
+
174
+ ```yaml
175
+ # .rigor.yml
176
+ bleeding_edge:
177
+ - dependent-closure-kill-oracle
178
+ ```
179
+
180
+ It composes with the site seed above — one decides what is
181
+ measured, the other where a catch may land — and either can be
182
+ adopted alone. The mutated file's verdict is unchanged, so this can
183
+ only **add** kills: the ratio moves up or not at all, and it cannot
184
+ turn a `--threshold` build red. A number recorded under it is still
185
+ not comparable with one recorded without it.
186
+
187
+ It costs more: each surviving breakage is re-analysed once per
188
+ dependent file (the caught ones stop at the mutated file), and the
189
+ dependency graph itself is one extra whole-project pass per run —
190
+ about a third more wall time on Rigor's own `lib`.
191
+
192
+ Worth knowing before you pay that: on the two codebases measured so
193
+ far (Rigor's own `lib`, redmine's `app/models`) it added **no**
194
+ kills. A breakage that survives there is one the analyzer reports
195
+ *nowhere* — not one it reports in a caller — which is a statement
196
+ about where the type net has teeth, and exactly what the
197
+ unprotected-site list is telling you.
198
+
82
199
  What *does* reach it is your tests.
83
200
 
84
201
  ## The fused view — types **and** tests (`--with-tests`)
@@ -236,6 +353,10 @@ them as a deep-dive, not a per-keystroke check:
236
353
  (suite runtime)`. A fast, well-scoped test command is the biggest
237
354
  lever.
238
355
  - **Cap with `--limit`** on `--include-dynamic` or large files.
356
+ - **Spread with `--workers=N`** on `--mutation` (and Tier 1). The
357
+ fused `--with-tests` tier stays sequential — the suite hook shells
358
+ out, and concurrent runs would race — so widen it with scope and
359
+ `--limit` instead.
239
360
 
240
361
  ## In CI
241
362
 
@@ -45,6 +45,28 @@ plugin id/version + config), so an unchanged second run skips the parse.
45
45
  | Rule | Severity | Fires when |
46
46
  | --- | --- | --- |
47
47
  | `plugin.rbs-inline.source-rbs-synthesis-failed` | info | rbs-inline could not parse a file; analysis falls back to no inline-RBS contribution and the diagnostic carries the upstream error |
48
+ | `plugin.rbs-inline.source-rbs-annotation-not-honoured` | info | an annotation parsed successfully but contributed nothing — the file's other annotations still apply. Today this means the `# @rbs module-self: Foo` spelling; see below |
49
+
50
+ ## Which inline-RBS dialect Rigor reads
51
+
52
+ There are two implementations of inline RBS: the
53
+ [`rbs-inline` gem](https://github.com/soutaro/rbs-inline), which this plugin
54
+ runs, and the `RBS::InlineParser` built into `rbs` 4.x. **Rigor reads the
55
+ gem's dialect** ([ADR-32](../../adr/32-rbs-inline-comment-ingestion.md) WD11).
56
+ They overlap almost entirely — `#:`, `@rbs` method types, `def self.`,
57
+ instance-variable annotations, `@rbs skip` all behave identically — but they
58
+ are not the same grammar, and one difference bites in practice:
59
+
60
+ | you write | Rigor honours it |
61
+ | --- | --- |
62
+ | `# @rbs module-self Comparable` | yes |
63
+ | `# @rbs module-self: Comparable` | **no** — this is the spelling in rbs's own `docs/inline.md` |
64
+
65
+ Rigor reports the second form as
66
+ `plugin.rbs-inline.source-rbs-annotation-not-honoured` rather than dropping it
67
+ in silence. Constructs the gem supports and the built-in parser does not —
68
+ `@rbs generic T`, `@rbs!` embedded RBS blocks, `@rbs inherits`, method
69
+ visibility — all work here.
48
70
 
49
71
  ## Configuration
50
72
 
@@ -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
@@ -119,6 +119,7 @@ module Rigor
119
119
  # the runner grows a new bare id.
120
120
  NON_CHECK_DIAGNOSTIC_IDS = %w[
121
121
  configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
122
+ source-rbs-annotation-not-honoured
122
123
  ].freeze
123
124
  end
124
125
  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
- LINE_SUPPRESSION_PATTERN = /#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
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 = /#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
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 = /#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
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
- /#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
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
- # (documentation prose like "`# rigor:disable <rule>` comments") is left alone as an ordinary
532
- # comment, matching the parse path, which never treats it as a suppression either.
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 the spelling in backticks stays
549
- # an ordinary comment — the same escape the empty-marker detection observes.
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
- return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
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
@@ -1271,13 +1301,23 @@ module Rigor
1271
1301
  # presence checks below cannot rule out a sound call.
1272
1302
  return nil unless Rigor::Reflection.rbs_class_known?("NilClass", scope: scope)
1273
1303
 
1274
- return nil unless union_contains_nil?(receiver_type)
1275
- return nil unless union_method_present_on_non_nil?(receiver_type, call_node.name, scope)
1276
- return nil if nil_class_has_method?(call_node.name, scope)
1304
+ return nil unless nil_bearing_union_witnesses?(receiver_type, call_node.name, scope)
1277
1305
 
1278
1306
  build_nil_receiver_diagnostic(path, call_node)
1279
1307
  end
1280
1308
 
1309
+ # The receiver-type half of the rule, factored out of the node-shape
1310
+ # guards above: the union must carry nil, must carry a non-nil arm the
1311
+ # presence question can be asked of, must support the method on every
1312
+ # non-nil arm (so the call is only wrong on the nil path), and the
1313
+ # method must be absent from `NilClass` (so the nil path really raises).
1314
+ def nil_bearing_union_witnesses?(receiver_type, method_name, scope)
1315
+ union_contains_nil?(receiver_type) &&
1316
+ union_has_nameable_non_nil_arm?(receiver_type) &&
1317
+ union_method_present_on_non_nil?(receiver_type, method_name, scope) &&
1318
+ !nil_class_has_method?(method_name, scope)
1319
+ end
1320
+
1281
1321
  def union_contains_nil?(union)
1282
1322
  union.members.any? { |member| nil_member?(member) }
1283
1323
  end
@@ -1302,6 +1342,23 @@ module Rigor
1302
1342
  (member.is_a?(Type::Nominal) && member.class_name == "NilClass")
1303
1343
  end
1304
1344
 
1345
+ # Possible-nil may witness only where the presence question below is
1346
+ # ANSWERABLE. `method_present_anywhere?` reports "present" for a
1347
+ # nameless arm (Dynamic / Top / Bot) — the permissive polarity the
1348
+ # union-undefined-method rule's FP safety rests on — so a union whose
1349
+ # non-nil arms are ALL nameless satisfied that gate vacuously and fired
1350
+ # on every method name, including names defined on no class anywhere.
1351
+ # That inverted the intent: the gate suppressed exactly where knowledge
1352
+ # exists (`String | nil` calling a nonexistent method stays silent) and
1353
+ # permitted exactly where none does. Requiring one nameable concrete
1354
+ # arm restores the polarity without touching the shared helper:
1355
+ # `String | nil` keeps firing, and so does `Dynamic | String | nil` —
1356
+ # the nameless arm stays permissive inside the all-arms check, which is
1357
+ # only about the arms' method surface.
1358
+ def union_has_nameable_non_nil_arm?(union)
1359
+ union.members.any? { |m| !nil_member?(m) && !concrete_class_name(m).nil? }
1360
+ end
1361
+
1305
1362
  # The non-nil members must collectively support the
1306
1363
  # method (i.e. for every non-nil member, the method
1307
1364
  # exists on its class via RBS or in-source discovery).
@@ -2075,13 +2132,25 @@ module Rigor
2075
2132
  param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
2076
2133
  mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
2077
2134
  return nil if mismatch.nil?
2078
- # ADR-67 WD6b the mismatching argument is an inferred-parameter local, whose type is an
2079
- # open-call-site lower bound; firing argument-type-mismatch against it is an FP by construction.
2080
- return nil if inferred_param_argument?(mismatch[:node], scope)
2135
+ return nil if inferred_param_mismatch_verdict?(call_node, mismatch, scope)
2081
2136
 
2082
2137
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
2083
2138
  end
2084
2139
 
2140
+ # ADR-67 WD6b — an argument-type-mismatch verdict resting on an open-call-site lower bound, on
2141
+ # either side of the call. The ARGUMENT side: the mismatching argument is an inferred-parameter
2142
+ # local, so firing against it is an FP by construction. The RECEIVER side: when the receiver roots
2143
+ # at an inferred parameter, the method whose parameter contract the argument was checked against
2144
+ # was itself resolved through a lower-bound type, so the whole verdict is speculative. The 2026-07-30
2145
+ # self-check surfaced the receiver half as a guard hole: seeding `env : RBS::Environment` activated
2146
+ # this rule on `env.unload(culprits)` and flagged a correct Array argument against `unload`'s
2147
+ # declared `Set[Pathname]` — an upstream signature stricter than its implementation, exactly the FP
2148
+ # class WD6b exists to suppress. The other guarded rules already declined on a param-rooted
2149
+ # receiver; this brings argument-type-mismatch in line.
2150
+ def inferred_param_mismatch_verdict?(call_node, mismatch, scope)
2151
+ inferred_param_argument?(mismatch[:node], scope) || inferred_param_receiver?(call_node, scope)
2152
+ end
2153
+
2085
2154
  # Single overload → the exact per-argument acceptance (unchanged).
2086
2155
  # Multiple overloads → the nil channel (a pure-`nil` argument every
2087
2156
  # overload rejects) plus, on non-coerce methods, the non-nil channel
@@ -2344,13 +2413,16 @@ module Rigor
2344
2413
  params.map { |param| param.type.to_s.delete_prefix("::") }.uniq.join(" | ")
2345
2414
  end
2346
2415
 
2347
- # ADR-58 parity for the nil channel: a declaration-sourced ivar read
2348
- # that types as nil is the same not-diagnostic-fuel case the union
2349
- # path gates in {#declaration_sourced_nil_only_mismatch?}; suppress it
2350
- # here too so a ctor-seeded `@x = nil` read passed as an argument does
2351
- # not fire on a working program's cross-method invariant.
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.
2352
2424
  def declaration_sourced_nil_argument?(arg, scope)
2353
- arg.is_a?(Prism::InstanceVariableReadNode) && scope.declaration_sourced?(:ivar, arg.name)
2425
+ DeclarationSourcedGuard.marked?(arg, scope)
2354
2426
  end
2355
2427
 
2356
2428
  def first_argument_mismatch(method_type, call_node, scope, param_overrides)
@@ -2413,14 +2485,15 @@ module Rigor
2413
2485
  !declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2414
2486
  end
2415
2487
 
2416
- # True when `arg` is a declaration-sourced ivar read whose rejection is
2488
+ # True when `arg` is a declaration-sourced read whose rejection is
2417
2489
  # caused solely by its nil constituent: stripping nil from the argument
2418
2490
  # type yields a type the parameter accepts (gradual mode). Mirrors the
2419
- # `possible-nil-receiver` WD1 gate, keyed on the ivar provenance mark
2420
- # rather than a local copy.
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.
2421
2495
  def declaration_sourced_nil_only_mismatch?(arg, arg_type, param_type, scope)
2422
- return false unless arg.is_a?(Prism::InstanceVariableReadNode)
2423
- return false unless scope.declaration_sourced?(:ivar, arg.name)
2496
+ return false unless DeclarationSourcedGuard.marked?(arg, scope)
2424
2497
  return false unless arg_type.is_a?(Type::Union)
2425
2498
  return false unless union_contains_nil?(arg_type)
2426
2499
 
@@ -2681,6 +2754,12 @@ module Rigor
2681
2754
 
2682
2755
  # Returns true when `override_visibility` is strictly more restrictive than `parent_visibility`
2683
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.
2684
2763
  def visibility_reduced?(parent_visibility, override_visibility)
2685
2764
  parent_rank = VISIBILITY_RANK[parent_visibility]
2686
2765
  override_rank = VISIBILITY_RANK[override_visibility]