rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -6,14 +6,11 @@ module Rigor
6
6
  module Analysis
7
7
  module DependencySourceInference
8
8
  # Per-run collection of gem-source-inference state. Holds
9
- # the resolved gems the walker MAY visit (slice 2b) plus
10
- # the unresolvable entries the runner SHOULD surface as
9
+ # the resolved gems the walker visits plus the unresolvable
10
+ # entries the runner surfaces as
11
11
  # `dynamic.dependency-source.gem-not-found` diagnostics.
12
- #
13
- # Slice 2a lands the data structure only; the dispatcher
14
- # tier consults {#contribution_for} but the lookup always
15
- # answers `nil` until slice 2b populates the method table
16
- # by walking the resolved gems' `roots:`.
12
+ # The method table is fully populated by {Walker} at build
13
+ # time; {#contribution_for} returns live entries.
17
14
  class Index
18
15
  attr_reader :resolved_gems, :unresolvable, :method_catalog, :budget_exceeded,
19
16
  :class_to_gem, :budget_overrun_strategy, :gem_modes
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "return_type_heuristic"
6
+ require_relative "../../source/constant_path"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -166,7 +167,7 @@ module Rigor
166
167
  # children under the same prefix so any inner class
167
168
  # definitions are still recorded under their own name.
168
169
  def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
169
- name = qualified_name_for(node.constant_path)
170
+ name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
170
171
  if name && node.body
171
172
  walk_node(node.body, qualified_prefix + [name], in_singleton_class, accumulator, budget)
172
173
  else
@@ -197,23 +198,6 @@ module Rigor
197
198
  return_type = ReturnTypeHeuristic.extract(node)
198
199
  accumulator[key] = CatalogEntry.new(kind: kind, return_type: return_type)
199
200
  end
200
-
201
- # Resolves a `Prism::ConstantPathNode` /
202
- # `Prism::ConstantReadNode` chain to its dot-separated
203
- # name (e.g. `"Foo::Bar"`). Returns nil for the rare
204
- # dynamic-prefix shape (`module ::Foo`-rooted variants
205
- # whose left side is a runtime expression) so the
206
- # walker treats those as opaque rather than guessing.
207
- def qualified_name_for(node)
208
- case node
209
- when Prism::ConstantReadNode then node.name.to_s
210
- when Prism::ConstantPathNode
211
- parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
212
- return nil if !node.parent.nil? && parent.nil?
213
-
214
- parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
215
- end
216
- end
217
201
  end
218
202
  end
219
203
  end
@@ -20,18 +20,9 @@ module Rigor
20
20
  # - {Builder.build} folds a `Configuration::Dependencies`
21
21
  # into a frozen {Index} carrying the partitioned outcomes.
22
22
  # - {Index} holds the per-run state the dispatcher tier
23
- # consults via `#contribution_for`. Slice 2a ships the
24
- # stub returning `nil`; slice 2b populates the method
25
- # table by walking each resolved gem's `roots:`.
26
- #
27
- # Per the ADR's "Implementation slicing" section, slice 2 is
28
- # split internally:
29
- #
30
- # - Slice 2a (this commit): gem resolution, index plumbing,
31
- # `Analysis::Runner` wiring, `dynamic.dependency-source.gem-not-found`
32
- # diagnostic for unresolvable entries.
33
- # - Slice 2b (next commit): walker, dispatcher tier
34
- # integration, `Type::Dynamic`-wrapped returns.
23
+ # consults via `#contribution_for`; the method table is
24
+ # fully populated by {Walker} walking each resolved gem's
25
+ # `roots:`.
35
26
  module DependencySourceInference
36
27
  end
37
28
  end
@@ -4,10 +4,11 @@ module Rigor
4
4
  module Analysis
5
5
  # Immutable storage for flow-sensitive facts attached to a Scope snapshot.
6
6
  #
7
- # The first implementation keeps the bucket model deliberately small:
8
- # callers can record local-binding and relational facts, invalidate all
9
- # facts that mention a target, and conservatively join two stores by
10
- # retaining only facts that both incoming edges share.
7
+ # Six buckets (see BUCKETS): local_binding, captured_local,
8
+ # object_content, global_storage, dynamic_origin, relational.
9
+ # Callers can record facts, invalidate all facts that mention a target,
10
+ # and conservatively join two stores by retaining only facts that both
11
+ # incoming edges share.
11
12
  class FactStore
12
13
  BUCKETS = %i[
13
14
  local_binding
@@ -29,19 +29,48 @@ module Rigor
29
29
  # - `severity_by_profile` — Hash of `:lenient` / `:balanced`
30
30
  # / `:strict` to the configured severity per profile, taken
31
31
  # from `Configuration::SeverityProfile::PROFILES`.
32
+ # - `evidence_tier` — `:high` / `:medium` / `:low` (or `nil`
33
+ # for informational helpers), Rigor's own confidence that a
34
+ # firing is a true positive, derived from the rule's firing
35
+ # gates. `:high` rules fire only on a concrete, statically-
36
+ # known type with no metaprogramming escape (Rigor's false-
37
+ # positive discipline has already filtered the uncertain
38
+ # cases); `:medium` rules rest on flow / inference proofs
39
+ # that inherit a documented FP envelope (loop / mutation /
40
+ # RBS-strictness modelling gaps); `:low` rules are
41
+ # resolution- or coverage-gap signals where a firing often
42
+ # reflects missing context rather than a definite bug. The
43
+ # tier routes a consumer's attention (and lets a downstream
44
+ # classifier promote a `:high` firing without cross-tool
45
+ # corroboration); it never feeds severity — that stays the
46
+ # `severity_profile:` decision.
32
47
  # - `since` — first version the rule shipped in.
33
48
  module RuleCatalog # rubocop:disable Metrics/ModuleLength
49
+ # Stable documentation home for a built-in rule. `documentation_url`
50
+ # appends a per-rule fragment that resolves to the rule's anchor in
51
+ # the published diagnostics catalogue; the page itself points at
52
+ # `rigor explain <rule>` as the authoritative per-rule reference.
53
+ # Mirrors the gemspec `documentation_uri` URL scheme (`…/tree/main`).
54
+ DOCUMENTATION_BASE = "https://github.com/rigortype/rigor/blob/main/docs/manual/04-diagnostics.md"
55
+
34
56
  class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
35
- :suppression, :severity_authored, :severity_by_profile, :since)
57
+ :suppression, :severity_authored, :severity_by_profile,
58
+ :evidence_tier, :since)
36
59
  def aliases
37
60
  CheckRules::LEGACY_RULE_ALIASES.select { |_legacy, canonical| canonical == id }.keys
38
61
  end
39
62
 
63
+ # Stable per-rule documentation URL (see {RuleCatalog.documentation_url}).
64
+ def documentation_url
65
+ RuleCatalog.documentation_url(id)
66
+ end
67
+
40
68
  # Hash-shaped form for `--format=json` consumers. Keys are
41
69
  # Strings so the payload is JSON-stable without a transform
42
- # pass.
70
+ # pass. `evidence_tier` is omitted when nil (informational
71
+ # helpers carry no confidence tier).
43
72
  def to_h
44
- {
73
+ base = {
45
74
  "id" => id,
46
75
  "aliases" => aliases,
47
76
  "summary" => summary,
@@ -50,8 +79,11 @@ module Rigor
50
79
  "suppression" => suppression,
51
80
  "severity_authored" => severity_authored.to_s,
52
81
  "severity_by_profile" => severity_by_profile.transform_keys(&:to_s).transform_values(&:to_s),
82
+ "documentation_url" => documentation_url,
53
83
  "since" => since
54
84
  }
85
+ base["evidence_tier"] = evidence_tier.to_s if evidence_tier
86
+ base
55
87
  end
56
88
  end
57
89
 
@@ -75,6 +107,7 @@ module Rigor
75
107
  "or `disable: [\"call.undefined-method\"]` in `.rigor.yml`.",
76
108
  severity_authored: :error,
77
109
  severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
110
+ evidence_tier: :high,
78
111
  since: "0.0.1"
79
112
  ),
80
113
 
@@ -98,9 +131,43 @@ module Rigor
98
131
  "`severity_overrides: { call.self-undefined-method: warning }` in `.rigor.yml`.",
99
132
  severity_authored: :warning,
100
133
  severity_by_profile: { lenient: :off, balanced: :off, strict: :off },
134
+ # Off by default and metaprogramming-prone — a firing on a
135
+ # class whose real surface the per-class scan cannot enumerate
136
+ # (C-extension, `class << self`, dynamic accessors) is the
137
+ # known FP mode, so a firing is a candidate to review, not a
138
+ # high-confidence bug.
139
+ evidence_tier: :low,
101
140
  since: "0.1.17"
102
141
  ),
103
142
 
143
+ CheckRules::RULE_UNRESOLVED_TOPLEVEL => Entry.new(
144
+ id: CheckRules::RULE_UNRESOLVED_TOPLEVEL,
145
+ summary: "Top-level implicit-self call resolves against no def, pre_eval: patch, or Kernel method.",
146
+ fires_when: [
147
+ "The call is an implicit-self call (no receiver) at top level (outside any class / module body).",
148
+ "Its name resolves against no same-file top-level `def`.",
149
+ "No ADR-17 `pre_eval:` monkey-patch on `Object` / `Kernel` declares it.",
150
+ "It is not a standard `Kernel` / `Object` private method (`puts`, `require`, `loop`, …)."
151
+ ],
152
+ does_not_fire_when: [
153
+ "The call has an explicit receiver, or sits inside a `def` / `class` / `module` body (ADR-24 WD3 " \
154
+ "stays lenient there).",
155
+ "A project file defines the name via a top-level `def` or an Object/Kernel monkey-patch listed in " \
156
+ "`.rigor.yml`'s `pre_eval:` (ADR-17).",
157
+ "The name is a Kernel/Object method visible in the loaded RBS environment."
158
+ ],
159
+ suppression: "`# rigor:disable call.unresolved-toplevel` on the call line, or list the defining " \
160
+ "file in `.rigor.yml`'s `pre_eval:` so the analyzer sees the top-level `def` / patch.",
161
+ severity_authored: :warning,
162
+ severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
163
+ # A firing is frequently a resolution gap — the defining file
164
+ # is not in the analyzed set or injects the method via a
165
+ # metaprogramming patch the analyzer does not see — rather than
166
+ # a definite typo, so it routes to the `pre_eval:` review path.
167
+ evidence_tier: :low,
168
+ since: "0.1.14"
169
+ ),
170
+
104
171
  CheckRules::RULE_WRONG_ARITY => Entry.new(
105
172
  id: CheckRules::RULE_WRONG_ARITY,
106
173
  summary: "Call's positional argument count is outside the declared overloads' envelope.",
@@ -117,6 +184,7 @@ module Rigor
117
184
  suppression: "`# rigor:disable call.wrong-arity`.",
118
185
  severity_authored: :error,
119
186
  severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
187
+ evidence_tier: :high,
120
188
  since: "0.0.1"
121
189
  ),
122
190
 
@@ -125,17 +193,22 @@ module Rigor
125
193
  summary: "Call passes an argument whose type the parameter cannot accept.",
126
194
  fires_when: [
127
195
  "The parameter type rejects the argument under `accepts(arg, mode: :gradual)`.",
128
- "Method has a single overload (multi-overload checking is deferred).",
196
+ "Single-overload: no overload accepts the arg class (ADR-64 non-nil channel).",
197
+ "Multi-overload: every overload rejects a pure-`nil` arg (ADR-64 nil channel) " \
198
+ "or every overload rejects a single concrete non-nil arg class (non-nil channel).",
129
199
  "Both sides have a non-Dynamic concrete type."
130
200
  ],
131
201
  does_not_fire_when: [
132
202
  "Either the parameter or the argument is `Dynamic[T]`.",
133
- "Method has multiple overloads.",
134
- "Method has `*rest_positionals`, required keywords, or trailing positionals."
203
+ "The call is a coerce-dispatch operator (`+`, `-`, `*`, `/`, `<`, `>`, …) — " \
204
+ "excluded because the `coerce` protocol makes acceptance undecidable.",
205
+ "Method has `*rest_positionals`, required keywords, or trailing positionals.",
206
+ "The argument type is a union (not a single concrete class)."
135
207
  ],
136
208
  suppression: "`# rigor:disable call.argument-type-mismatch`.",
137
209
  severity_authored: :error,
138
210
  severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
211
+ evidence_tier: :high,
139
212
  since: "0.0.2"
140
213
  ),
141
214
 
@@ -155,6 +228,7 @@ module Rigor
155
228
  suppression: "`# rigor:disable call.possible-nil-receiver`.",
156
229
  severity_authored: :error,
157
230
  severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
231
+ evidence_tier: :high,
158
232
  since: "0.0.2"
159
233
  ),
160
234
 
@@ -171,6 +245,9 @@ module Rigor
171
245
  suppression: "Remove the `dump_type` call (it's a debug helper, not a real diagnostic).",
172
246
  severity_authored: :info,
173
247
  severity_by_profile: { lenient: :info, balanced: :info, strict: :error },
248
+ # Informational helper, not a correctness finding — no
249
+ # confidence tier applies.
250
+ evidence_tier: nil,
174
251
  since: "0.0.1"
175
252
  ),
176
253
 
@@ -187,6 +264,7 @@ module Rigor
187
264
  suppression: "Update the assertion to the actual inferred type, or correct the source.",
188
265
  severity_authored: :error,
189
266
  severity_by_profile: { lenient: :error, balanced: :error, strict: :error },
267
+ evidence_tier: :high,
190
268
  since: "0.0.1"
191
269
  ),
192
270
 
@@ -205,6 +283,7 @@ module Rigor
205
283
  suppression: "`# rigor:disable flow.always-raises`.",
206
284
  severity_authored: :error,
207
285
  severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
286
+ evidence_tier: :high,
208
287
  since: "0.0.3"
209
288
  ),
210
289
 
@@ -224,6 +303,9 @@ module Rigor
224
303
  "points at the dead branch, not the predicate, so the suppression goes there).",
225
304
  severity_authored: :warning,
226
305
  severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
306
+ # The literal-only firing envelope makes the deadness provable
307
+ # from syntax alone — no inference uncertainty.
308
+ evidence_tier: :high,
227
309
  since: "0.1.2"
228
310
  ),
229
311
 
@@ -246,6 +328,11 @@ module Rigor
246
328
  suppression: "`# rigor:disable always-truthy-condition` on the predicate line.",
247
329
  severity_authored: :warning,
248
330
  severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
331
+ # Rests on inferred-constant folding, which inherits the
332
+ # loop / mutation FP envelope the `does_not_fire_when` guards
333
+ # narrow — true positive in the common case, but not literal-
334
+ # provable like `unreachable-branch`.
335
+ evidence_tier: :medium,
249
336
  since: "0.1.2"
250
337
  ),
251
338
 
@@ -271,6 +358,9 @@ module Rigor
271
358
  # ADR-47 WD4: balanced stays :info (one notch below its `flow.*`
272
359
  # siblings' :warning) until the regression-corpus FP gate is green.
273
360
  severity_by_profile: { lenient: :info, balanced: :info, strict: :warning },
361
+ # Narrowing-driven proof that inherits the `always-truthy`
362
+ # FP envelope; balanced keeps it `:info` pending the corpus gate.
363
+ evidence_tier: :medium,
274
364
  since: "0.1.17"
275
365
  ),
276
366
 
@@ -292,6 +382,11 @@ module Rigor
292
382
  suppression: "`# rigor:disable dead-assignment` on the offending line, or rename the local to `_<name>`.",
293
383
  severity_authored: :warning,
294
384
  severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
385
+ # The unread-write proof is reliable, but it flags a code
386
+ # smell rather than a runtime fault, and the syntactic write
387
+ # classification has narrow corners (the `does_not_fire_when`
388
+ # exclusions).
389
+ evidence_tier: :medium,
295
390
  since: "0.1.2"
296
391
  ),
297
392
 
@@ -313,6 +408,10 @@ module Rigor
313
408
  suppression: "`# rigor:disable def.return-type-mismatch`.",
314
409
  severity_authored: :warning,
315
410
  severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
411
+ # Depends on re-typing the body against an authored RBS return;
412
+ # RBS strict-on-returns plus incomplete body inference makes a
413
+ # firing usually-right but not concrete-call certain.
414
+ evidence_tier: :medium,
316
415
  since: "0.1.0"
317
416
  ),
318
417
 
@@ -333,6 +432,7 @@ module Rigor
333
432
  suppression: "`# rigor:disable method-visibility-mismatch`.",
334
433
  severity_authored: :error,
335
434
  severity_by_profile: { lenient: :warning, balanced: :error, strict: :error },
435
+ evidence_tier: :high,
336
436
  since: "0.1.2"
337
437
  ),
338
438
 
@@ -356,6 +456,10 @@ module Rigor
356
456
  suppression: "`# rigor:disable def.override-visibility-reduced` on the override.",
357
457
  severity_authored: :warning,
358
458
  severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
459
+ # Both the override and the shadowed ancestor visibility are
460
+ # statically observed from project source — the substitutability
461
+ # violation is concrete.
462
+ evidence_tier: :high,
359
463
  since: "0.1.15"
360
464
  ),
361
465
 
@@ -383,6 +487,9 @@ module Rigor
383
487
  suppression: "`# rigor:disable def.override-return-widened` on the override.",
384
488
  severity_authored: :warning,
385
489
  severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
490
+ # Gated on both-sides-authored RBS and a resolvable subtype
491
+ # relationship, so a firing is a concrete covariance violation.
492
+ evidence_tier: :high,
386
493
  since: "0.1.15"
387
494
  ),
388
495
 
@@ -413,6 +520,9 @@ module Rigor
413
520
  suppression: "`# rigor:disable def.override-param-narrowed` on the override.",
414
521
  severity_authored: :warning,
415
522
  severity_by_profile: { lenient: :off, balanced: :warning, strict: :error },
523
+ # Gated on both-sides-authored RBS and a resolvable subtype
524
+ # relationship, so a firing is a concrete contravariance violation.
525
+ evidence_tier: :high,
416
526
  since: "0.1.15"
417
527
  ),
418
528
 
@@ -434,6 +544,9 @@ module Rigor
434
544
  suppression: "`# rigor:disable ivar-write-mismatch` on the offending write.",
435
545
  severity_authored: :error,
436
546
  severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
547
+ # Both writes resolve to concrete classes before firing; the
548
+ # union / Dynamic / clear-idiom escapes are excluded.
549
+ evidence_tier: :high,
437
550
  since: "0.1.2"
438
551
  )
439
552
  }.freeze
@@ -466,6 +579,40 @@ module Rigor
466
579
  def all
467
580
  ENTRIES.values.sort_by(&:id)
468
581
  end
582
+
583
+ # Rigor's confidence tier (`:high` / `:medium` / `:low`) that a
584
+ # firing of `token` is a true positive, or nil for an
585
+ # informational rule (`dump.type`) or an unknown / non-built-in
586
+ # token (plugin and `rbs_extended.*` rules carry no built-in
587
+ # tier). Resolves legacy aliases. See {Entry}'s `evidence_tier`
588
+ # documentation for the tier semantics.
589
+ def evidence_tier(token)
590
+ entries = resolve(token)
591
+ return nil unless entries.size == 1
592
+
593
+ entries.first.evidence_tier
594
+ end
595
+
596
+ # Stable documentation URL for `token`, or nil for an unknown /
597
+ # non-built-in token. The URL is the published diagnostics
598
+ # catalogue page anchored at the rule's per-rule anchor
599
+ # (`#rule-<id-with-dots-as-dashes>`); the page itself names
600
+ # `rigor explain <rule>` as the authoritative per-rule reference.
601
+ # Resolves legacy aliases to the canonical id.
602
+ def documentation_url(token)
603
+ entries = resolve(token)
604
+ return nil unless entries.size == 1
605
+
606
+ "#{DOCUMENTATION_BASE}##{doc_anchor(entries.first.id)}"
607
+ end
608
+
609
+ # The per-rule fragment a `documentation_url` points at:
610
+ # `call.undefined-method` → `rule-call-undefined-method`. The
611
+ # `04-diagnostics.md` catalogue carries the matching `<a id>`
612
+ # anchors.
613
+ def doc_anchor(rule_id)
614
+ "rule-#{rule_id.tr('.', '-')}"
615
+ end
469
616
  end
470
617
  end
471
618
  end
@@ -423,23 +423,6 @@ module Rigor
423
423
  unresolved + lossy
424
424
  end
425
425
 
426
- # ADR-10 slice 5c — drains the per-run
427
- # {DependencySourceInference::BoundaryCrossReporter} into
428
- # `dynamic.dependency-source.boundary-cross` `:info`
429
- # diagnostics. Each event flags a call site where RBS
430
- # dispatch produced a concrete answer AND a `mode: :full`
431
- # opt-in gem's source catalog ALSO contains an entry for
432
- # the same `(class_name, method_name)` — i.e., both
433
- # contracts have an opinion. RBS still wins on the
434
- # dispatch result; the diagnostic is purely advisory so
435
- # the user can verify the two contracts haven't drifted.
436
- #
437
- # Severity profile re-stamps the rule per project taste.
438
- # The diagnostic carries no `path` / `line` / `column`
439
- # because the crossing is per-method-per-gem, not
440
- # per-call-site — the diagnostic anchors at `.rigor.yml`
441
- # like the other `dependency-source.*` diagnostics that
442
- # report on opt-in configuration.
443
426
  # ADR-32 WD6 — drains the per-run
444
427
  # {Plugin::SourceRbsSynthesisReporter} into
445
428
  # `source-rbs-synthesis-failed` `:info` diagnostics. Each
@@ -469,6 +452,23 @@ module Rigor
469
452
  end
470
453
  end
471
454
 
455
+ # ADR-10 slice 5c — drains the per-run
456
+ # {DependencySourceInference::BoundaryCrossReporter} into
457
+ # `dynamic.dependency-source.boundary-cross` `:info`
458
+ # diagnostics. Each event flags a call site where RBS
459
+ # dispatch produced a concrete answer AND a `mode: :full`
460
+ # opt-in gem's source catalog ALSO contains an entry for
461
+ # the same `(class_name, method_name)` — i.e., both
462
+ # contracts have an opinion. RBS still wins on the
463
+ # dispatch result; the diagnostic is purely advisory so
464
+ # the user can verify the two contracts haven't drifted.
465
+ #
466
+ # Severity profile re-stamps the rule per project taste.
467
+ # The diagnostic carries no `path` / `line` / `column`
468
+ # because the crossing is per-method-per-gem, not
469
+ # per-call-site — the diagnostic anchors at `.rigor.yml`
470
+ # like the other `dependency-source.*` diagnostics that
471
+ # report on opt-in configuration.
472
472
  def boundary_cross_diagnostics
473
473
  return [] if @boundary_cross_reporter.empty?
474
474
 
@@ -45,7 +45,8 @@ module Rigor
45
45
  :discovered_class_sources,
46
46
  :discovered_method_visibilities,
47
47
  :discovered_methods,
48
- :data_member_layouts
48
+ :data_member_layouts,
49
+ :struct_member_layouts
49
50
  )
50
51
 
51
52
  # @param configuration [Rigor::Configuration]
@@ -139,7 +140,8 @@ module Rigor
139
140
  discovered_class_sources: def_index.fetch(:class_sources),
140
141
  discovered_method_visibilities: def_index.fetch(:method_visibilities),
141
142
  discovered_methods: def_index.fetch(:methods),
142
- data_member_layouts: def_index.fetch(:data_member_layouts)
143
+ data_member_layouts: def_index.fetch(:data_member_layouts),
144
+ struct_member_layouts: def_index.fetch(:struct_member_layouts)
143
145
  )
144
146
  end
145
147
 
@@ -182,7 +184,8 @@ module Rigor
182
184
  discovered_class_sources: nil,
183
185
  discovered_method_visibilities: nil,
184
186
  discovered_methods: nil,
185
- data_member_layouts: nil
187
+ data_member_layouts: nil,
188
+ struct_member_layouts: nil
186
189
  )
187
190
  end
188
191
 
@@ -278,11 +281,9 @@ module Rigor
278
281
  # `#diagnostics_for_file` raise envelope in
279
282
  # `plugin_runtime_error_diagnostic`.
280
283
  #
281
- # Slice 3 visits plugins in registration order. Slice 5
282
- # introduces topological ordering by `manifest(consumes:)`
283
- # so producers always run before consumers; until then,
284
- # `Configuration#plugins` order MUST be producer-first if
285
- # cross-plugin dependencies exist.
284
+ # `Plugin::Loader` returns plugins in topological order by
285
+ # `manifest(consumes:)` (ADR-9 slice 5), so producers
286
+ # always run before consumers.
286
287
  def plugin_prepare_diagnostics(plugin_registry)
287
288
  return [] if plugin_registry.empty?
288
289
 
@@ -58,11 +58,10 @@ module Rigor
58
58
  # slices route real producers through it.
59
59
  # @param workers [Integer] ADR-15 Phase 4b — when greater
60
60
  # than zero, per-file analysis dispatches across a pool of
61
- # N Ractor workers built around {WorkerSession}. Default
62
- # `0` keeps the sequential code path bit-for-bit
63
- # unchanged. Phase 4c will wire the CLI / `.rigor.yml`
64
- # surface that produces non-zero values; this slice
65
- # leaves the parameter as a programmatic opt-in only.
61
+ # N workers. Default `0` keeps the sequential code path
62
+ # bit-for-bit unchanged. Controlled via the
63
+ # `RIGOR_RACTOR_WORKERS` env var or `.rigor.yml`
64
+ # `parallel.workers:` (Phase 4c, fully wired).
66
65
  # @param collect_stats [Boolean] when true (default), `#run`
67
66
  # builds a {RunStats} summary exposed via `result.stats`
68
67
  # — this forces the RBS env build at end-of-run so the
@@ -163,6 +162,7 @@ module Rigor
163
162
  @project_discovered_method_visibilities = {}.freeze
164
163
  @project_discovered_methods = {}.freeze
165
164
  @project_data_member_layouts = {}.freeze
165
+ @project_struct_member_layouts = {}.freeze
166
166
  build_collaborators
167
167
  end
168
168
 
@@ -479,6 +479,7 @@ module Rigor
479
479
  end
480
480
  @project_discovered_methods = result.discovered_methods if result.discovered_methods
481
481
  @project_data_member_layouts = result.data_member_layouts if result.data_member_layouts
482
+ @project_struct_member_layouts = result.struct_member_layouts if result.struct_member_layouts
482
483
  end
483
484
  private :run_project_pre_passes, :adopt_prebuilt_project_scan, :apply_pre_passes_result
484
485
 
@@ -837,7 +838,7 @@ module Rigor
837
838
  tables[:discovered_method_visibilities] = @project_discovered_method_visibilities
838
839
  end
839
840
  tables[:discovered_methods] = @project_discovered_methods unless @project_discovered_methods.empty?
840
- tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
841
+ seed_member_layout_tables(tables)
841
842
  # ADR-46 slice 1 — the class-declaration source map is read only by
842
843
  # the ancestry accessors during dependency recording, so seed it
843
844
  # only when recording is on; a normal run never carries it.
@@ -847,6 +848,16 @@ module Rigor
847
848
  tables
848
849
  end
849
850
 
851
+ # ADR-48 — seed the Data + Struct member-layout tables (each only when
852
+ # non-empty). Extracted to keep {#project_scope_seed_tables} under the
853
+ # complexity budget.
854
+ def seed_member_layout_tables(tables)
855
+ tables[:data_member_layouts] = @project_data_member_layouts unless @project_data_member_layouts.empty?
856
+ return if @project_struct_member_layouts.empty?
857
+
858
+ tables[:struct_member_layouts] = @project_struct_member_layouts
859
+ end
860
+
850
861
  # ADR-46 slice 1 — when dependency recording is enabled, wrap the
851
862
  # per-file analysis so the cross-file reads its inference makes are
852
863
  # captured into `file_dependencies[path]`. Off by default: a normal
@@ -21,10 +21,9 @@ module Rigor
21
21
  # reach the recorder. This module is the ADR-46 / ADR-47 "collect at
22
22
  # evaluation time, never recompute" lesson applied to self-calls.
23
23
  #
24
- # A later slice consumes the recorded misses behind a confidently-
25
- # closed-class gate to emit `call.self-undefined-method`, behind its
26
- # own external-corpus false-positive gate. This slice (4a) lands the
27
- # plumbing OFF by default — {active?} is false on a normal run, so the
24
+ # ADR-24 slice 4 (`call.self-undefined-method`) consumes the recorded
25
+ # misses behind a confidently-closed-class gate (see `CheckRules` L775).
26
+ # The rule ships `:off` by default — {active?} is false on a normal run, so the
28
27
  # instrumented choke-point pays a single integer read and records
29
28
  # nothing. Recording is purely observational; it never changes a
30
29
  # diagnostic.
@@ -21,21 +21,19 @@ module Rigor
21
21
  module Analysis
22
22
  # ADR-15 Phase 4a — per-worker analysis substrate.
23
23
  # [ADR-15](../../../docs/adr/15-ractor-concurrency.md)
24
- # § Phase 4 carves the eventual Ractor-isolated worker pool
25
- # into three sub-phases; this is the substrate that 4b will
26
- # wrap in `Ractor.new` and 4c will gate behind
27
- # `RIGOR_RACTOR_WORKERS`. NO Ractor in the loop yet 4a
28
- # exists so the per-worker ownership boundary is testable in
29
- # the absence of any Ractor coordination.
24
+ # § Phase 4 carves the Ractor-isolated worker pool into sub-phases;
25
+ # 4a/4b/4c all landed, but the Ractor pool (4b) is blocked by Ruby
26
+ # Bug #22075 (UAF) the active pool backend is fork (ADR-15 Amendment).
27
+ # This class exists so the per-worker ownership boundary is testable
28
+ # independently of any pool coordinator.
30
29
  #
31
30
  # The constructor takes only `Ractor.shareable?` inputs:
32
31
  #
33
32
  # - `configuration` — Phase 2a ({Rigor::Configuration} is
34
33
  # `Ractor.shareable?`).
35
- # - `cache_store` — frozen-shareable handle is NOT a precondition;
36
- # future 4b workers build their OWN Store at the shared
37
- # `cache_root` directory. 4a accepts an already-built Store
38
- # for the no-Ractor coordinator path.
34
+ # - `cache_store` — the fork backend passes the parent runner's
35
+ # pre-built Store (`cache_store: @cache_store` in PoolCoordinator);
36
+ # workers share it rather than building their own at `cache_root`.
39
37
  # - `plugin_blueprints` — Phase 3a
40
38
  # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
41
39
  # - `explain` — Boolean.
@@ -234,11 +232,9 @@ module Rigor
234
232
  Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
235
233
  end
236
234
 
237
- # Mirrors {Runner#build_trust_policy}. Workers under Phase
238
- # 4b will need the same trust derivation, and the
239
- # configuration is already shareable, so deriving it inside
235
+ # Mirrors {Runner#build_trust_policy}. Deriving trust inside
240
236
  # the session keeps the substrate decoupled from the
241
- # coordinator's helper.
237
+ # coordinator; configuration is already Ractor-shareable.
242
238
  def build_trust_policy
243
239
  trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
244
240
  roots = [Dir.pwd]