rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -8,12 +8,18 @@ require_relative "../source/constant_path"
8
8
  require_relative "../type"
9
9
  require_relative "diagnostic"
10
10
  require_relative "dependency_recorder"
11
+ require_relative "check_rules/rule_ids"
12
+ require_relative "check_rules/inferred_param_guard"
11
13
  require_relative "check_rules/rule_walk"
12
14
  require_relative "check_rules/always_truthy_condition_collector"
13
15
  require_relative "check_rules/unreachable_clause_collector"
16
+ require_relative "check_rules/shadowed_rescue_collector"
14
17
  require_relative "check_rules/dead_assignment_collector"
18
+ require_relative "check_rules/duplicate_hash_key_collector"
19
+ require_relative "check_rules/return_in_ensure_collector"
15
20
  require_relative "check_rules/ivar_write_collector"
16
21
  require_relative "check_rules/main_pass_collector"
22
+ require_relative "check_rules/void_value_use_collector"
17
23
  require_relative "check_rules/self_closedness_scanner"
18
24
 
19
25
  module Rigor
@@ -44,88 +50,8 @@ module Rigor
44
50
  # loader (RBS-blind environments, unknown stdlib).
45
51
  # rubocop:disable Metrics/ModuleLength
46
52
  module CheckRules
47
- # Canonical identifiers for each rule. Per ADR-8 §
48
- # "Diagnostic ID family hierarchy", rule names are
49
- # `family.rule-name` two-segment strings; the families
50
- # group diagnostics by where they originate
51
- # (`call.*` for call-site rules, `flow.*` for flow-analysis
52
- # proofs, `assert.*` for runtime-assertion rules,
53
- # `dump.*` for debug helpers, `def.*` for method-definition
54
- # rules). Used by the configuration `disable:` list and the
55
- # in-source `# rigor:disable <rule>` suppression comment
56
- # system; new rules MUST register here so user configuration
57
- # can refer to them.
58
- RULE_UNDEFINED_METHOD = "call.undefined-method"
59
- RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
60
- RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
61
- RULE_WRONG_ARITY = "call.wrong-arity"
62
- RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
63
- RULE_NIL_RECEIVER = "call.possible-nil-receiver"
64
- RULE_DUMP_TYPE = "dump.type"
65
- RULE_ASSERT_TYPE = "assert.type-mismatch"
66
- RULE_ALWAYS_RAISES = "flow.always-raises"
67
- RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
68
- RULE_RETURN_TYPE = "def.return-type-mismatch"
69
- RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
70
- RULE_OVERRIDE_VISIBILITY_REDUCED = "def.override-visibility-reduced"
71
- RULE_OVERRIDE_RETURN_WIDENED = "def.override-return-widened"
72
- RULE_OVERRIDE_PARAM_NARROWED = "def.override-param-narrowed"
73
- RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
74
- RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
75
- RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
76
- RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
77
-
78
- ALL_RULES = [
79
- RULE_UNDEFINED_METHOD,
80
- RULE_SELF_UNDEFINED_METHOD,
81
- RULE_UNRESOLVED_TOPLEVEL,
82
- RULE_WRONG_ARITY,
83
- RULE_ARGUMENT_TYPE,
84
- RULE_NIL_RECEIVER,
85
- RULE_DUMP_TYPE,
86
- RULE_ASSERT_TYPE,
87
- RULE_ALWAYS_RAISES,
88
- RULE_UNREACHABLE_BRANCH,
89
- RULE_DEAD_ASSIGNMENT,
90
- RULE_ALWAYS_TRUTHY_CONDITION,
91
- RULE_UNREACHABLE_CLAUSE,
92
- RULE_RETURN_TYPE,
93
- RULE_VISIBILITY_MISMATCH,
94
- RULE_OVERRIDE_VISIBILITY_REDUCED,
95
- RULE_OVERRIDE_RETURN_WIDENED,
96
- RULE_OVERRIDE_PARAM_NARROWED,
97
- RULE_IVAR_WRITE_MISMATCH
98
- ].freeze
99
-
100
- # Backward-compat alias table (ADR-8 § "Backward
101
- # compatibility"). Existing user code with
102
- # `# rigor:disable undefined-method` /
103
- # `disable: [undefined-method]` keeps working — the
104
- # legacy unprefixed identifiers map to their canonical
105
- # `family.rule-name` form here. Removing the aliases is
106
- # a future ADR once user code has migrated; until then,
107
- # both spellings resolve identically.
108
- LEGACY_RULE_ALIASES = {
109
- "undefined-method" => RULE_UNDEFINED_METHOD,
110
- "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
111
- "wrong-arity" => RULE_WRONG_ARITY,
112
- "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
113
- "possible-nil-receiver" => RULE_NIL_RECEIVER,
114
- "dump-type" => RULE_DUMP_TYPE,
115
- "assert-type" => RULE_ASSERT_TYPE,
116
- "always-raises" => RULE_ALWAYS_RAISES,
117
- "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
118
- "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
119
- "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
120
- "dead-assignment" => RULE_DEAD_ASSIGNMENT,
121
- "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
122
- "unreachable-clause" => RULE_UNREACHABLE_CLAUSE
123
- }.freeze
124
-
125
- # Family wildcard — a `<family>` token in a suppression
126
- # comment or `disable:` list disables every rule whose
127
- # canonical id starts with `<family>.`. Per ADR-8 § "1".
128
- RULE_FAMILIES = %w[call flow assert dump def].freeze
53
+ # ADR-87 WD4 the canonical rule-id table lives in the light `check_rules/rule_ids.rb` (required at the
54
+ # top of this file) so {Analysis::RuleCatalog} can read it without loading the engine.
129
55
 
130
56
  # ADR-35 slice 1 — bound for the `def.override-visibility-reduced`
131
57
  # ancestor walk, and the public > protected > private ordering
@@ -171,13 +97,34 @@ module Rigor
171
97
  collectors = node_collectors || run_node_collectors(path, root, scope_index)
172
98
  diagnostics = collectors[:main_pass].results.dup
173
99
  diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
174
- diagnostics.concat(always_truthy_condition_diagnostics(path, collectors[:always_truthy].results))
175
- diagnostics.concat(unreachable_clause_diagnostics(path, collectors[:unreachable_clauses].results))
176
- diagnostics.concat(ivar_write_mismatch_diagnostics(path, collectors[:ivar_writes].results))
177
- diagnostics.concat(dead_assignment_diagnostics(path, collectors[:dead_assignments].results))
100
+ COLLECTOR_DIAGNOSTIC_BUILDERS.each do |role, builder|
101
+ diagnostics.concat(send(builder, path, collectors[role].results))
102
+ end
103
+ # Suppression-marker validation (`suppression.*`) runs BEFORE the filter so its own diagnostics are
104
+ # suppressible like any other rule — `# rigor:disable suppression.unknown-rule` on the offending
105
+ # comment's line works, with no regress (the token itself is known, so it never re-fires).
106
+ diagnostics.concat(suppression_marker_diagnostics(path, comments))
178
107
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
179
108
  end
180
109
 
110
+ # The per-collector diagnostic builders {.diagnose} folds over, in the historical emission order
111
+ # (value-use-void → always-truthy → unreachable-clause → shadowed-rescue → ivar-write →
112
+ # dead-assignment → duplicate-hash-key → return-in-ensure). Keys match {.build_node_collectors}'
113
+ # roles; each value is a `(path, results)` module_function on this module. `void_value_use` leads
114
+ # because its standalone walk used to run before this fold, and the emission order is byte-identical
115
+ # output, not an implementation detail.
116
+ COLLECTOR_DIAGNOSTIC_BUILDERS = {
117
+ void_value_use: :void_value_use_diagnostics,
118
+ always_truthy: :always_truthy_condition_diagnostics,
119
+ unreachable_clauses: :unreachable_clause_diagnostics,
120
+ shadowed_rescues: :shadowed_rescue_diagnostics,
121
+ ivar_writes: :ivar_write_mismatch_diagnostics,
122
+ dead_assignments: :dead_assignment_diagnostics,
123
+ duplicate_hash_keys: :duplicate_hash_key_diagnostics,
124
+ return_in_ensure: :return_in_ensure_diagnostics
125
+ }.freeze
126
+ private_constant :COLLECTOR_DIAGNOSTIC_BUILDERS
127
+
181
128
  # The verbatim per-node dispatch of the former inline main pass
182
129
  # (`diagnose`'s `Source::NodeWalker.each` `case`), now invoked by
183
130
  # {MainPassCollector} on the shared {RuleWalk}. Returns the
@@ -210,10 +157,14 @@ module Rigor
210
157
  def build_node_collectors(path, scope_index)
211
158
  {
212
159
  main_pass: MainPassCollector.new(->(node) { main_pass_node_diagnostics(path, node, scope_index) }),
160
+ void_value_use: VoidValueUseCollector.new(scope_index),
213
161
  always_truthy: AlwaysTruthyConditionCollector.new(scope_index),
214
162
  unreachable_clauses: UnreachableClauseCollector.new(scope_index),
163
+ shadowed_rescues: ShadowedRescueCollector.new(scope_index),
215
164
  ivar_writes: IvarWriteCollector.new(scope_index),
216
- dead_assignments: DeadAssignmentCollector.new(scope_index)
165
+ dead_assignments: DeadAssignmentCollector.new(scope_index),
166
+ duplicate_hash_keys: DuplicateHashKeyCollector.new(scope_index),
167
+ return_in_ensure: ReturnInEnsureCollector.new(scope_index)
217
168
  }
218
169
  end
219
170
 
@@ -313,6 +264,7 @@ module Rigor
313
264
  dump_type_diagnostic(path, node, scope_index),
314
265
  assert_type_diagnostic(path, node, scope_index),
315
266
  always_raises_diagnostic(path, node, scope_index),
267
+ raise_non_exception_diagnostic(path, node, scope_index),
316
268
  visibility_mismatch_diagnostic(path, node, scope_index)
317
269
  ].compact
318
270
  end
@@ -357,6 +309,31 @@ module Rigor
357
309
  end
358
310
  end
359
311
 
312
+ # v0.3.0 — `flow.duplicate-hash-key`. Emits a diagnostic for each LATER occurrence of a repeated
313
+ # LITERAL key within one Hash literal (braced or bare-kwargs) — Ruby keeps the last entry silently
314
+ # at runtime, so the earlier value is dead. The
315
+ # `Analysis::CheckRules::DuplicateHashKeyCollector` describes the value-pinned-literal-only
316
+ # envelope (symbols / plain strings / integers / floats / true / false / nil; never
317
+ # cross-kind, never interpolation / constants / calls / splats).
318
+ def duplicate_hash_key_diagnostics(path, duplicate_keys)
319
+ duplicate_keys.map do |result|
320
+ build_duplicate_hash_key_diagnostic(path, result)
321
+ end
322
+ end
323
+
324
+ # v0.3.0 — `flow.return-in-ensure`. One diagnostic per explicit
325
+ # `return` lexically inside an `ensure` clause body: it silently
326
+ # discards the method's in-flight return value and swallows any
327
+ # in-flight exception. Purely syntactic; the
328
+ # `Analysis::CheckRules::ReturnInEnsureCollector` describes the
329
+ # frame-aware envelope (nested def / lambda / `define_method`
330
+ # blocks are excluded, plain blocks are not).
331
+ def return_in_ensure_diagnostics(path, results)
332
+ results.map do |result|
333
+ build_return_in_ensure_diagnostic(path, result[:return_node])
334
+ end
335
+ end
336
+
360
337
  # v0.1.2 — `flow.always-truthy-condition`. Fires on
361
338
  # `if` / `unless` / ternary predicates whose inferred
362
339
  # type is a `Type::Constant` AND that don't fall in
@@ -380,6 +357,24 @@ module Rigor
380
357
  end
381
358
  end
382
359
 
360
+ # ADR-100 WD2 — `static.value-use.void`. Its value-context slot inspection rides the shared per-node
361
+ # {RuleWalk} like the other collectors, so the file is not re-traversed for it. Each result is a
362
+ # value-context use of a call whose author-declared `-> void` return the engine recovered to `top`.
363
+ def void_value_use_diagnostics(path, results)
364
+ results.map do |result|
365
+ build_void_value_use_diagnostic(path, result)
366
+ end
367
+ end
368
+
369
+ # `flow.shadowed-rescue-clause` — one diagnostic per rescue clause every earlier-comparable class of
370
+ # which is already caught by an earlier clause of the same chain (see {ShadowedRescueCollector} for
371
+ # the ancestry-certainty envelope).
372
+ def shadowed_rescue_diagnostics(path, results)
373
+ results.map do |result|
374
+ build_shadowed_rescue_diagnostic(path, result)
375
+ end
376
+ end
377
+
383
378
  def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
384
379
  return [] if writes.size < 2
385
380
 
@@ -454,6 +449,23 @@ module Rigor
454
449
  FILE_SUPPRESSION_PATTERN = /#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
455
450
  private_constant :FILE_SUPPRESSION_PATTERN
456
451
 
452
+ # A `rigor:disable[-file]` marker word regardless of whether any rule tokens follow. Used only by the
453
+ # `suppression.empty` detection — the two suppression patterns above require at least one token
454
+ # character, so a bare `# rigor:disable` never reaches them. The lookahead keeps
455
+ # `rigor:disable-something-else` from counting as a marker.
456
+ BARE_SUPPRESSION_MARKER = /#\s*rigor:disable(?<file>-file)?(?![\w-])(?<rest>.*)/
457
+ private_constant :BARE_SUPPRESSION_MARKER
458
+
459
+ # A `rigor:` marker word that is NOT part of Rigor's suppression grammar but reads like an attempted
460
+ # suppression — the RuboCop-reflex spellings `rigor:disable-next-line <rules>` and
461
+ # `rigor:enable <rules>`. These are invisible to both suppression patterns above (the hyphenated
462
+ # suffix fails LINE_SUPPRESSION_PATTERN's `\s+` and BARE_SUPPRESSION_MARKER's lookahead), so without
463
+ # surveillance they silently suppress nothing. Matches `disable-<suffix>` for any suffix other than
464
+ # `file`, and `enable` with or without a suffix.
465
+ UNKNOWN_SUPPRESSION_MARKER =
466
+ /#\s*rigor:(?<marker>disable-(?!file(?![\w-]))[\w-]+|enable(?:-[\w-]+)?)(?![\w-])(?<rest>.*)/
467
+ private_constant :UNKNOWN_SUPPRESSION_MARKER
468
+
457
469
  # @return [Array<(Hash{Integer => Set}, Set)>] pair of
458
470
  # `(line_suppressions, file_suppressions)`. Line
459
471
  # suppressions are keyed by source line number; file
@@ -478,6 +490,128 @@ module Rigor
478
490
  end
479
491
  end
480
492
 
493
+ # PHPStan-`IgnoreParseErrorRule`-modelled surveillance over the suppression markers themselves: a
494
+ # malformed or ineffective `# rigor:disable` / `# rigor:disable-file` comment must not silently no-op
495
+ # (the typo'd `# rigor:disable call.undefined-metod` suppresses nothing and the user never learns).
496
+ # Emits `suppression.unknown-rule` for every marker token that resolves to no known identifier, and
497
+ # `suppression.empty` for a marker that lists no rules at all. The MATCHING semantics are deliberately
498
+ # unchanged — an unknown token is still kept verbatim per the diagnostic-policy spec — so this is
499
+ # additive surveillance only. Both diagnostics run before {.filter_suppressed} and flow through it,
500
+ # so they are themselves suppressible (`# rigor:disable suppression.unknown-rule`) with no regress:
501
+ # that token is known, so acknowledging it never re-fires the rule.
502
+ def suppression_marker_diagnostics(path, comments)
503
+ comments.each_with_object([]) do |comment, diagnostics|
504
+ source = comment.location.slice
505
+ if (match = FILE_SUPPRESSION_PATTERN.match(source))
506
+ validate_suppression_tokens(match[:rules], "rigor:disable-file", path, comment, diagnostics)
507
+ elsif (match = LINE_SUPPRESSION_PATTERN.match(source))
508
+ validate_suppression_tokens(match[:rules], "rigor:disable", path, comment, diagnostics)
509
+ else
510
+ diagnose_bare_suppression_marker(path, comment, source, diagnostics)
511
+ end
512
+ end
513
+ end
514
+
515
+ def validate_suppression_tokens(raw, marker, path, comment, diagnostics)
516
+ tokens = raw.to_s.split(/[\s,]+/).reject(&:empty?)
517
+ if tokens.empty?
518
+ diagnostics << empty_suppression_diagnostic(path, comment, marker)
519
+ return
520
+ end
521
+
522
+ tokens.each do |token|
523
+ next if known_suppression_token?(token)
524
+
525
+ diagnostics << unknown_suppression_rule_diagnostic(path, comment, marker, token)
526
+ end
527
+ end
528
+
529
+ # 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.
533
+ def diagnose_bare_suppression_marker(path, comment, source, diagnostics)
534
+ bare = BARE_SUPPRESSION_MARKER.match(source)
535
+ if bare
536
+ return unless bare[:rest].match?(/\A[\s,]*\z/)
537
+
538
+ marker = bare[:file] ? "rigor:disable-file" : "rigor:disable"
539
+ diagnostics << empty_suppression_diagnostic(path, comment, marker)
540
+ return
541
+ end
542
+
543
+ diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
544
+ end
545
+
546
+ # `# rigor:disable-next-line <rule>` / `# rigor:enable <rule>` — a marker word Rigor's grammar does
547
+ # 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.
550
+ def diagnose_unknown_suppression_marker(path, comment, source, diagnostics)
551
+ unknown = UNKNOWN_SUPPRESSION_MARKER.match(source)
552
+ return if unknown.nil?
553
+
554
+ rest = unknown[:rest]
555
+ return unless rest.match?(/\A[\s,]*\z/) || rest.match?(/\A\s+[\w.,\s-]+\z/)
556
+
557
+ diagnostics << unknown_suppression_marker_diagnostic(path, comment, unknown[:marker])
558
+ end
559
+
560
+ # True when a suppression token resolves to a diagnostic identifier some producer can emit: a
561
+ # canonical CheckRules id, a legacy alias, the `all` wildcard, a family wildcard, a bare
562
+ # non-catalogue engine id, or a dotted id under a known non-check family (`rbs_extended.*`,
563
+ # `dynamic.*`, `rbs.*`, `pre-eval.*`, and any `plugin.`-prefixed id — plugins load dynamically, so
564
+ # their rule vocabulary cannot be enumerated here and under-warning is the FP-safe direction).
565
+ def known_suppression_token?(token)
566
+ return true if token == "all"
567
+ return true if ALL_RULES.include?(token) || LEGACY_RULE_ALIASES.key?(token) ||
568
+ RULE_FAMILIES.include?(token) || NON_CHECK_DIAGNOSTIC_IDS.include?(token)
569
+
570
+ family, rest = token.split(".", 2)
571
+ !rest.nil? && NON_CHECK_DIAGNOSTIC_FAMILIES.include?(family)
572
+ end
573
+
574
+ def unknown_suppression_rule_diagnostic(path, comment, marker, token)
575
+ Diagnostic.new(
576
+ path: path,
577
+ line: comment.location.start_line,
578
+ column: comment.location.start_column + 1,
579
+ message: "unknown rule `#{token}` in `# #{marker}` — the token matches no known rule, alias, " \
580
+ "or family, so this suppression has no effect. Likely a typo; `rigor explain <rule>` " \
581
+ "lists the canonical ids.",
582
+ severity: :warning,
583
+ rule: RULE_SUPPRESSION_UNKNOWN_RULE,
584
+ source_family: :builtin
585
+ )
586
+ end
587
+
588
+ def unknown_suppression_marker_diagnostic(path, comment, marker)
589
+ Diagnostic.new(
590
+ path: path,
591
+ line: comment.location.start_line,
592
+ column: comment.location.start_column + 1,
593
+ message: "unrecognised suppression marker `rigor:#{marker}` — Rigor's markers are " \
594
+ "`# rigor:disable <rules>` (suppresses on its own line) and " \
595
+ "`# rigor:disable-file <rules>`, so this comment suppresses nothing.",
596
+ severity: :warning,
597
+ rule: RULE_SUPPRESSION_UNKNOWN_MARKER,
598
+ source_family: :builtin
599
+ )
600
+ end
601
+
602
+ def empty_suppression_diagnostic(path, comment, marker)
603
+ Diagnostic.new(
604
+ path: path,
605
+ line: comment.location.start_line,
606
+ column: comment.location.start_column + 1,
607
+ message: "`# #{marker}` lists no rules, so this suppression has no effect. Name the rules to " \
608
+ "suppress (`# #{marker} call.undefined-method`) or use `# #{marker} all`.",
609
+ severity: :warning,
610
+ rule: RULE_SUPPRESSION_EMPTY,
611
+ source_family: :builtin
612
+ )
613
+ end
614
+
481
615
  # Expands a list of user-supplied rule tokens into the
482
616
  # canonical-id set per ADR-8 § "Backward compatibility".
483
617
  # `disabled_rules` accepts unprefixed legacy names
@@ -500,12 +634,16 @@ module Rigor
500
634
  class << self
501
635
  private
502
636
 
503
- def undefined_method_diagnostic(path, call_node, scope_index)
637
+ def undefined_method_diagnostic(path, call_node, scope_index) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
504
638
  return nil if call_node.receiver.nil?
505
639
 
506
640
  scope = scope_index[call_node]
507
641
  return nil if scope.nil?
508
642
 
643
+ # ADR-67 WD6b — an inferred-parameter receiver's type is an open-call-site lower bound; firing
644
+ # undefined-method against it is an FP by construction. Decline.
645
+ return nil if inferred_param_receiver?(call_node, scope)
646
+
509
647
  # N3 — a safe-navigation call (`recv&.m`) never dispatches on the
510
648
  # nil edge of its receiver: at runtime it short-circuits to nil.
511
649
  # A receiver that types as exactly `nil` yields nil with no call at
@@ -727,6 +865,28 @@ module Rigor
727
865
  }.freeze
728
866
  private_constant :CONSTANT_CLASSES
729
867
 
868
+ # ADR-67 WD6b — true when this call's receiver is *rooted at* a pristine inferred-parameter local (the
869
+ # call-site union seeded at method entry). Every negative in-body rule (`call.undefined-method`,
870
+ # wrong-arity, argument-type-mismatch, possible-nil, visibility) declines on such a receiver: an open
871
+ # call-site set makes the inferred type a *lower bound*, so a diagnostic against it is a false positive
872
+ # by construction (the same reasoning that keeps ADR-67 WD1 non-negotiable at the parameter boundary,
873
+ # carried into the body). No-op on a normal `check` run — `inferred_param?` is false unless the
874
+ # `parameter_inference:` gate seeded the table.
875
+ def inferred_param_receiver?(call_node, scope)
876
+ inferred_param_rooted?(call_node.receiver, scope)
877
+ end
878
+
879
+ # ADR-67 WD6b — the argument-position analogue: true when `arg` is rooted at an inferred-parameter
880
+ # local. The argument-type-mismatch rule declines on it for the same lower-bound reason.
881
+ def inferred_param_argument?(arg, scope)
882
+ inferred_param_rooted?(arg, scope)
883
+ end
884
+
885
+ # ADR-67 WD6b — see {CheckRules::InferredParamGuard} for the shared root-walk contract.
886
+ def inferred_param_rooted?(node, scope)
887
+ InferredParamGuard.rooted?(node, scope)
888
+ end
889
+
730
890
  def constant_class_name(value)
731
891
  CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
732
892
  nil
@@ -942,6 +1102,10 @@ module Rigor
942
1102
  scope = scope_index[call_node]
943
1103
  return nil if scope.nil?
944
1104
 
1105
+ # ADR-67 WD6b — an inferred-parameter receiver's type is an open-call-site lower bound; a wrong-arity
1106
+ # firing against it is an FP by construction. Decline.
1107
+ return nil if inferred_param_receiver?(call_node, scope)
1108
+
945
1109
  receiver_type = scope.type_of(call_node.receiver)
946
1110
  class_name = concrete_class_name(receiver_type)
947
1111
  return nil if class_name.nil?
@@ -1094,6 +1258,11 @@ module Rigor
1094
1258
  # flow-observed nil keeps firing exactly as before.
1095
1259
  return nil if scope.declaration_sourced?(:local, call_node.receiver.name)
1096
1260
 
1261
+ # ADR-67 WD6b — an inferred-parameter receiver's type (incl. any nil constituent unioned in from a
1262
+ # nil call site) is an open-call-site lower bound; a possible-nil firing against it is an FP by
1263
+ # construction. Decline.
1264
+ return nil if scope.inferred_param?(call_node.receiver.name)
1265
+
1097
1266
  receiver_type = scope.type_of(call_node.receiver)
1098
1267
  return nil unless receiver_type.is_a?(Type::Union)
1099
1268
 
@@ -1396,6 +1565,178 @@ module Rigor
1396
1565
  )
1397
1566
  end
1398
1567
 
1568
+ # `call.raise-non-exception` — `raise x` / `fail x` where the first argument's statically-inferred
1569
+ # type is provably NOT a legal raise operand (PHPStan ThrowExprTypeRule analogue). Legal operands:
1570
+ # an Exception class object, an Exception instance, a String (raises RuntimeError), or any object
1571
+ # whose class defines `#exception` (the duck protocol `raise` consults at runtime). Anything else
1572
+ # (`raise 42`, `raise :sym`, `raise nil` — an explicit nil argument is a TypeError, unlike bare
1573
+ # `raise` which re-raises `$!`) raises TypeError at runtime.
1574
+ #
1575
+ # Conservative envelope (FP discipline):
1576
+ # - Implicit-self `raise` / `fail` only; an explicit-receiver call is a user method, not Kernel#raise.
1577
+ # - Silent when the project redefines `raise` / `fail` anywhere the call could resolve (a same-file
1578
+ # toplevel def, an Object/Kernel monkey-patch, or a def on the enclosing class — in source or via
1579
+ # `pre_eval:`).
1580
+ # - Only the first positional argument is checked; splat / kwargs / forwarded first args bail.
1581
+ # - Fires only on a concrete verdict: Dynamic / Top / unknown / unresolved types are silent, and a
1582
+ # Union fires only when EVERY arm is independently illegal.
1583
+ # - A Class operand (`Type::Singleton`) is exact, so both `:disjoint` and `:superclass` orderings
1584
+ # against Exception are provably illegal — but only when the class is RBS-known, not an ADR-26
1585
+ # open receiver / synthesized stub, and its singleton defines no `exception` method.
1586
+ # - An instance operand's nominal class is NOT exact (a runtime subclass could define `#exception`
1587
+ # or be an Exception), so only `:disjoint` fires, and the generic carriers (`Class` / `Module` /
1588
+ # `Object` / `BasicObject`) plus module-typed values (any includer could be an Exception) bail.
1589
+ RAISE_METHOD_NAMES = %i[raise fail].freeze
1590
+ private_constant :RAISE_METHOD_NAMES
1591
+
1592
+ # Instance types whose nominal class subsumes exception values (or class objects), so a "disjoint
1593
+ # from Exception" ordering proves nothing about the runtime value. `Object` / `BasicObject` order as
1594
+ # `:superclass` (already silent) but are listed for explicitness.
1595
+ RAISE_UNEXACT_INSTANCE_CLASSES = %w[Class Module Object BasicObject].freeze
1596
+ private_constant :RAISE_UNEXACT_INSTANCE_CLASSES
1597
+
1598
+ def raise_non_exception_diagnostic(path, call_node, scope_index)
1599
+ return nil unless call_node.receiver.nil?
1600
+ return nil unless RAISE_METHOD_NAMES.include?(call_node.name)
1601
+ return nil unless call_node.block.nil?
1602
+
1603
+ arg = first_positional_raise_operand(call_node)
1604
+ return nil if arg.nil?
1605
+
1606
+ scope = scope_index[arg] || scope_index[call_node]
1607
+ return nil if scope.nil?
1608
+ return nil if raise_redefined_in_scope?(scope, call_node.name)
1609
+
1610
+ operand_type = scope.type_of(arg)
1611
+ return nil unless raise_operand_verdict(operand_type, scope) == :illegal
1612
+
1613
+ build_raise_non_exception_diagnostic(path, call_node, operand_type)
1614
+ end
1615
+
1616
+ def first_positional_raise_operand(call_node)
1617
+ args = call_node.arguments&.arguments
1618
+ return nil if args.nil? || args.empty?
1619
+
1620
+ first = args.first
1621
+ return nil if first.is_a?(Prism::SplatNode) || first.is_a?(Prism::KeywordHashNode) ||
1622
+ first.is_a?(Prism::BlockArgumentNode) || first.is_a?(Prism::ForwardingArgumentsNode)
1623
+
1624
+ first
1625
+ end
1626
+
1627
+ # True when a project-side definition of `raise` / `fail` could shadow Kernel's at this call site:
1628
+ # a same-file toplevel def, a monkey-patch on Object / Kernel (in source or `pre_eval:`), or a def
1629
+ # on the enclosing class (instance or singleton side — implicit self dispatches to either depending
1630
+ # on context, and being silent for both is the cheap conservative answer).
1631
+ def raise_redefined_in_scope?(scope, name)
1632
+ return true if scope.top_level_def_for(name)
1633
+ return true if source_declared_method?(scope, "Object", name, :instance)
1634
+ return true if source_declared_method?(scope, "Kernel", name, :instance)
1635
+
1636
+ self_type = scope.self_type
1637
+ return false unless self_type.respond_to?(:class_name)
1638
+
1639
+ class_name = self_type.class_name
1640
+ return false if class_name.nil?
1641
+
1642
+ source_declared_method?(scope, class_name, name, :instance) ||
1643
+ source_declared_method?(scope, class_name, name, :singleton)
1644
+ end
1645
+
1646
+ # Trinary verdict — `:legal` / `:illegal` / `:unknown`. Only `:illegal` fires; anything the engine
1647
+ # cannot prove stays `:unknown` (silent).
1648
+ def raise_operand_verdict(type, scope)
1649
+ case type
1650
+ when Type::Union
1651
+ verdicts = type.members.map { |member| raise_operand_verdict(member, scope) }
1652
+ return :illegal if verdicts.all?(:illegal)
1653
+
1654
+ verdicts.all?(:legal) ? :legal : :unknown
1655
+ when Type::Singleton
1656
+ raise_class_operand_verdict(type.class_name, scope)
1657
+ else
1658
+ raise_instance_operand_verdict(type, scope)
1659
+ end
1660
+ end
1661
+
1662
+ # A `Type::Singleton` names ONE exact class / module object, so an ordering that places it outside
1663
+ # Exception's ancestry (`:disjoint`, or `:superclass` — e.g. `raise Object`) is a proof, provided
1664
+ # the singleton also lacks an `exception` method (`raise Klass` calls `Klass.exception`). A module
1665
+ # constant orders `:disjoint` and fires unless its singleton defines `exception`.
1666
+ def raise_class_operand_verdict(class_name, scope)
1667
+ return :unknown if class_name.nil? || scope.environment.nil?
1668
+ return :unknown if unbounded_receiver_surface?(class_name, scope)
1669
+ return :unknown if Rigor::Reflection.discovered_class?(class_name, scope: scope)
1670
+ return :unknown unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
1671
+
1672
+ case Rigor::Reflection.class_ordering(class_name, "Exception", scope: scope)
1673
+ when :equal, :subclass then :legal
1674
+ when :superclass, :disjoint
1675
+ raise_duck_exception?(class_name, :singleton, scope) ? :legal : :illegal
1676
+ else
1677
+ :unknown
1678
+ end
1679
+ end
1680
+
1681
+ # An instance operand: legal when its class is String-family (raises RuntimeError) or an Exception
1682
+ # descendant; illegal only when the class is fully known, exact enough (not a generic metaclass /
1683
+ # module carrier), provably disjoint from BOTH, and defines no `#exception`. `:superclass` stays
1684
+ # silent — a value typed `Object` may well BE an Exception at runtime.
1685
+ def raise_instance_operand_verdict(type, scope)
1686
+ class_name = concrete_class_name(type)
1687
+ return :unknown if class_name.nil? || scope.environment.nil?
1688
+ return :unknown if RAISE_UNEXACT_INSTANCE_CLASSES.include?(class_name)
1689
+ return :unknown if unbounded_receiver_surface?(class_name, scope)
1690
+ # A project-declared class's ancestry must not be proven from RBS alone: a `sig/` declaration that
1691
+ # omits the superclass (`class Conflict` for a source-side `class Conflict < StandardError`)
1692
+ # defaults to Object in the RBS env, which would read as "disjoint from Exception" for a class
1693
+ # that IS one at runtime (the rule's first self-check firing, `Plugin::FactStore::Conflict`).
1694
+ # Source-discovered classes stay silent.
1695
+ return :unknown if Rigor::Reflection.discovered_class?(class_name, scope: scope)
1696
+ return :unknown unless Rigor::Reflection.rbs_class_known?(class_name, scope: scope)
1697
+ return :unknown if scope.environment.rbs_module?(class_name)
1698
+
1699
+ string_ordering = Rigor::Reflection.class_ordering(class_name, "String", scope: scope)
1700
+ return :legal if %i[equal subclass].include?(string_ordering)
1701
+
1702
+ case Rigor::Reflection.class_ordering(class_name, "Exception", scope: scope)
1703
+ when :equal, :subclass then :legal
1704
+ when :disjoint
1705
+ raise_duck_exception?(class_name, :instance, scope) ? :legal : :illegal
1706
+ else
1707
+ :unknown
1708
+ end
1709
+ end
1710
+
1711
+ # Whether the class carries an `exception` method on the given side — from RBS, an in-source `def`,
1712
+ # or a `pre_eval:` patch. An unbuildable definition returns true (assume the duck) so structural
1713
+ # RBS gaps never manufacture a firing.
1714
+ def raise_duck_exception?(class_name, kind, scope)
1715
+ return true if source_declared_method?(scope, class_name, :exception, kind)
1716
+
1717
+ definition = if kind == :singleton
1718
+ Rigor::Reflection.singleton_definition(class_name, scope: scope)
1719
+ else
1720
+ Rigor::Reflection.instance_definition(class_name, scope: scope)
1721
+ end
1722
+ return true if definition.nil?
1723
+
1724
+ !definition.methods[:exception].nil?
1725
+ end
1726
+
1727
+ def build_raise_non_exception_diagnostic(path, call_node, operand_type)
1728
+ Diagnostic.from_message_loc(
1729
+ call_node,
1730
+ rule: RULE_RAISE_NON_EXCEPTION,
1731
+ path: path,
1732
+ message: "`#{call_node.name}' operand types as #{operand_type.describe(:short)}, which is not " \
1733
+ "an Exception class, an Exception instance, a String, or an object defining " \
1734
+ "`#exception' — this raises TypeError at runtime",
1735
+ severity: :error,
1736
+ method_name: call_node.name.to_s
1737
+ )
1738
+ end
1739
+
1399
1740
  # v0.1.2 — `flow.unreachable-branch`. Fires when an `IfNode` / `UnlessNode` whose predicate is a
1400
1741
  # literal `true` / `false` / `nil` (or a literal numeric / string / symbol whose Ruby truthiness is
1401
1742
  # known at-a-glance) has an observable dead branch. The diagnostic points at the dead branch (not
@@ -1468,6 +1809,10 @@ module Rigor
1468
1809
  scope = scope_index[call_node]
1469
1810
  return nil if scope.nil?
1470
1811
 
1812
+ # ADR-67 WD6b — an inferred-parameter receiver's nominal type is an open-call-site lower bound; a
1813
+ # private-method visibility firing against it is an FP by construction. Decline.
1814
+ return nil if inferred_param_receiver?(call_node, scope)
1815
+
1471
1816
  receiver_type = scope.type_of(call_node.receiver)
1472
1817
  return nil unless receiver_type.is_a?(Type::Nominal)
1473
1818
 
@@ -1532,6 +1877,17 @@ module Rigor
1532
1877
  )
1533
1878
  end
1534
1879
 
1880
+ def build_void_value_use_diagnostic(path, result)
1881
+ Diagnostic.from_node(
1882
+ result.void_node,
1883
+ rule: RULE_VALUE_USE_VOID,
1884
+ path: path,
1885
+ message: "value use of `void': `#{result.origin.label}' declares `-> void', so its return " \
1886
+ "recovers to `top' and should not be used as a value",
1887
+ severity: :warning
1888
+ )
1889
+ end
1890
+
1535
1891
  def unreachable_clause_message(result)
1536
1892
  subject = result.subject_name
1537
1893
  kw = result.keyword
@@ -1547,6 +1903,24 @@ module Rigor
1547
1903
  end
1548
1904
  end
1549
1905
 
1906
+ def build_shadowed_rescue_diagnostic(path, result)
1907
+ Diagnostic.from_node(
1908
+ result.clause,
1909
+ rule: RULE_SHADOWED_RESCUE_CLAUSE,
1910
+ path: path,
1911
+ message: shadowed_rescue_message(result),
1912
+ severity: :warning
1913
+ )
1914
+ end
1915
+
1916
+ def shadowed_rescue_message(result)
1917
+ earlier = result.earlier_sources.each_with_index.map do |source, index|
1918
+ "`#{source}' (line #{result.earlier_lines[index]})"
1919
+ end
1920
+ "shadowed `#{result.clause_source}': every exception class it names is already caught " \
1921
+ "by the earlier #{earlier.join(' and ')} clause#{'s' if earlier.size > 1}, so this clause can never run"
1922
+ end
1923
+
1550
1924
  def build_dead_assignment_diagnostic(path, write_node, def_node)
1551
1925
  Diagnostic.from_name_loc(
1552
1926
  write_node,
@@ -1557,6 +1931,32 @@ module Rigor
1557
1931
  )
1558
1932
  end
1559
1933
 
1934
+ # The diagnostic points at the LATER occurrence (the entry that wins at runtime) and names the
1935
+ # first occurrence's line, so the fix — delete or rename one of the two — is visible from the
1936
+ # message alone.
1937
+ def build_duplicate_hash_key_diagnostic(path, result)
1938
+ first_line = result[:first_key_node].location.start_line
1939
+ Diagnostic.from_node(
1940
+ result[:key_node],
1941
+ rule: RULE_DUPLICATE_HASH_KEY,
1942
+ path: path,
1943
+ message: "duplicate hash key `#{result[:key_label]}' in the same literal; this entry " \
1944
+ "overwrites the value first set at line #{first_line}",
1945
+ severity: :warning
1946
+ )
1947
+ end
1948
+
1949
+ def build_return_in_ensure_diagnostic(path, return_node)
1950
+ Diagnostic.from_location(
1951
+ return_node.keyword_loc,
1952
+ rule: RULE_RETURN_IN_ENSURE,
1953
+ path: path,
1954
+ message: "`return' inside `ensure' discards the method's in-flight return value " \
1955
+ "and swallows any in-flight exception",
1956
+ severity: :warning
1957
+ )
1958
+ end
1959
+
1560
1960
  def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
1561
1961
  Diagnostic.from_name_loc(
1562
1962
  node,
@@ -1675,6 +2075,9 @@ module Rigor
1675
2075
  param_overrides = Rigor::RbsExtended.param_type_override_map(method_def, environment: scope.environment)
1676
2076
  mismatch = argument_mismatch(method_def.method_types, call_node, scope, param_overrides)
1677
2077
  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)
1678
2081
 
1679
2082
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1680
2083
  end
@@ -1716,7 +2119,7 @@ module Rigor
1716
2119
  arguments = call_node.arguments&.arguments || []
1717
2120
  arguments.each_with_index do |arg, index|
1718
2121
  arg_type = scope.type_of(arg)
1719
- params = overload_positional_params(functions, index)
2122
+ params = overload_positional_params(method_types, index)
1720
2123
  next if params.nil? # arity divergence — some overload lacks a param here
1721
2124
 
1722
2125
  mismatch =
@@ -1751,11 +2154,30 @@ module Rigor
1751
2154
  # The matching positional RBS param across every overload, or nil when
1752
2155
  # any overload has no param at `index` (arity divergence — the
1753
2156
  # wrong-arity rule's concern, not this one's).
1754
- def overload_positional_params(functions, index)
1755
- params = functions.map { |function| (function.required_positionals + function.optional_positionals)[index] }
2157
+ def overload_positional_params(method_types, index)
2158
+ params = method_types.map do |method_type|
2159
+ function = method_type.type
2160
+ param = (function.required_positionals + function.optional_positionals)[index]
2161
+ param && resolve_param_bounds(param, method_type)
2162
+ end
1756
2163
  params.any?(&:nil?) ? nil : params
1757
2164
  end
1758
2165
 
2166
+ # Substitutes each bounded method-level type parameter for its bound, so
2167
+ # `[I < _ToInt] (I index) -> …` is walked as `(_ToInt index) -> …`. A bare
2168
+ # `Variable` is undecidable to the acceptance walk and admits everything,
2169
+ # which silently disables both channels for the whole overload; the bound
2170
+ # constrains the argument exactly as an ordinary param of that type would.
2171
+ # Load-bearing since rbs 4.1 rewrote core signatures into this form
2172
+ # (`Array#fetch`'s block overload is `[I < _ToInt, T] (I index) { … }`).
2173
+ def resolve_param_bounds(param, method_type)
2174
+ bounded = method_type.type_params.select(&:upper_bound)
2175
+ return param if bounded.empty?
2176
+
2177
+ substitution = RBS::Substitution.build(bounded.map(&:name), bounded.map(&:upper_bound))
2178
+ param.map_type { |type| type.sub(substitution) }
2179
+ end
2180
+
1759
2181
  # The class names whose instances `nil` IS — `NilClass` and every
1760
2182
  # ancestor. A parameter typed as any other class instance rejects nil.
1761
2183
  NIL_COMPATIBLE_CLASS_NAMES = %w[NilClass Object BasicObject Kernel].to_set.freeze
@@ -2169,11 +2591,16 @@ module Rigor
2169
2591
  declared_return_union(method_def, scope.environment)
2170
2592
  end
2171
2593
 
2172
- def declared_return_union(method_def, _environment)
2594
+ # `type_vars:` — ADR-35 WD9 tier 1. When the caller supplies a generic-instantiation
2595
+ # substitution map (parent-side, keyed by the parent class's declared type-parameter names),
2596
+ # a `-> T` return is translated at its instantiated type (`-> Integer`) rather than degrading
2597
+ # to `Dynamic[Top]`. The default empty map is the pre-WD9 behaviour, under which any type
2598
+ # variable degrades to `Dynamic[Top]` and the rule stays silent.
2599
+ def declared_return_union(method_def, _environment, type_vars: {})
2173
2600
  translated = method_def.method_types.filter_map do |mt|
2174
2601
  Inference::RbsTypeTranslator.translate(
2175
2602
  mt.type.return_type,
2176
- self_type: nil, instance_type: nil, type_vars: {}
2603
+ self_type: nil, instance_type: nil, type_vars: type_vars
2177
2604
  )
2178
2605
  rescue StandardError
2179
2606
  nil
@@ -2401,8 +2828,9 @@ module Rigor
2401
2828
  return nil if resolved.nil?
2402
2829
 
2403
2830
  scope, override_method, parent_class, parent_method = resolved
2831
+ parent_type_vars = ancestor_instantiation_type_vars(scope, parent_class)
2404
2832
  override_return = declared_return_union(override_method, scope.environment)
2405
- parent_return = declared_return_union(parent_method, scope.environment)
2833
+ parent_return = declared_return_union(parent_method, scope.environment, type_vars: parent_type_vars)
2406
2834
  return nil if override_return.nil? || parent_return.nil?
2407
2835
  return nil if dynamic_top?(parent_return) # untyped / unbound-generic parent contract
2408
2836
 
@@ -2441,6 +2869,56 @@ module Rigor
2441
2869
  name.to_s.delete_prefix("::")
2442
2870
  end
2443
2871
 
2872
+ # ADR-35 WD9 tier 1 — generic-instantiation-aware comparison. Builds the substitution map
2873
+ # `{ parent_type_param_name => instantiated Rigor::Type }` for the parent contract as the
2874
+ # overriding subclass instantiates it (RBS `class Sub < Parent[Concrete]` / `include
2875
+ # _Iface[Concrete]`). Mirrors the ADR-4 Phase 2d dispatcher zip (`build_type_vars`): the
2876
+ # parent's declared type-parameter names zipped against the subclass-side instantiation args.
2877
+ #
2878
+ # Returns `{}` — restoring the pre-WD9 behaviour under which any parent type variable degrades
2879
+ # to `Dynamic[Top]` and the rule stays silent — whenever the subclass does not instantiate the
2880
+ # ancestor generically, the RBS definition cannot be built, arities disagree, or an argument
2881
+ # fails to translate. This is the FP-safety contract: substitution only ever *adds* precision
2882
+ # (a `-> T` compared at `-> Integer`); an unbound / unresolved generic keeps degrading to
2883
+ # silence, never a false `:no`.
2884
+ def ancestor_instantiation_type_vars(scope, parent_class)
2885
+ self_type = scope.self_type
2886
+ return {} unless self_type.respond_to?(:class_name)
2887
+
2888
+ args = ancestor_instantiation_args(scope, self_type.class_name.to_s, parent_class)
2889
+ return {} if args.nil? || args.empty?
2890
+
2891
+ param_names = Reflection.class_type_param_names(parent_class, scope: scope)
2892
+ return {} if param_names.empty? || param_names.size != args.size
2893
+
2894
+ translated = args.map do |arg|
2895
+ Inference::RbsTypeTranslator.translate(arg, self_type: nil, instance_type: nil, type_vars: {})
2896
+ rescue StandardError
2897
+ nil
2898
+ end
2899
+ return {} if translated.any?(&:nil?)
2900
+
2901
+ param_names.zip(translated).to_h
2902
+ end
2903
+
2904
+ # The RBS type arguments the subclass applies to `parent_class` in its ancestry, or nil when
2905
+ # the subclass has no RBS definition or does not name that ancestor. Reads the resolved
2906
+ # instance-ancestor list (`RBS::Definition#ancestors`), whose `Ancestor::Instance` entries carry
2907
+ # the instantiation `.args` for each superclass / included module. Fail-soft: any RBS build
2908
+ # error yields nil, which the caller treats as "no instantiation" and degrades to silence.
2909
+ def ancestor_instantiation_args(scope, subclass_name, parent_class)
2910
+ definition = Reflection.instance_definition(subclass_name, scope: scope)
2911
+ return nil if definition.nil?
2912
+
2913
+ target = normalize_class_name(parent_class)
2914
+ ancestor = definition.ancestors.ancestors.find do |anc|
2915
+ anc.respond_to?(:args) && normalize_class_name(anc.name.to_s) == target
2916
+ end
2917
+ ancestor&.args
2918
+ rescue ::RBS::BaseError, StandardError
2919
+ nil
2920
+ end
2921
+
2444
2922
  def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
2445
2923
  Diagnostic.from_name_loc(
2446
2924
  def_node,
@@ -2469,9 +2947,10 @@ module Rigor
2469
2947
  resolved = resolve_authored_override(def_node, scope_index)
2470
2948
  return nil if resolved.nil?
2471
2949
 
2472
- _scope, override_method, parent_class, parent_method = resolved
2950
+ scope, override_method, parent_class, parent_method = resolved
2951
+ parent_type_vars = ancestor_instantiation_type_vars(scope, parent_class)
2473
2952
  override_params = positional_param_types(override_method)
2474
- parent_params = positional_param_types(parent_method)
2953
+ parent_params = positional_param_types(parent_method, type_vars: parent_type_vars)
2475
2954
  return nil if override_params.nil? || parent_params.nil?
2476
2955
 
2477
2956
  index = first_narrowed_param_index(override_params, parent_params)
@@ -2487,7 +2966,11 @@ module Rigor
2487
2966
  # parameter list is not introspectable. Per-position translation failures yield `nil` at that slot
2488
2967
  # (skipped by the comparison). `self`/`instance` translate with `self_type: nil` (→
2489
2968
  # `Dynamic[Top]`), matching the return-side handling.
2490
- def positional_param_types(method_def)
2969
+ # `type_vars:` — ADR-35 WD9 tier 1, parent side only. Substitutes the generic-instantiation
2970
+ # map (parent class's type params → the subclass's instantiation) so a parent `(T)` parameter
2971
+ # is compared at its instantiated type. Empty (the default, and the override side) keeps the
2972
+ # pre-WD9 degrade-to-`Dynamic[Top]` behaviour.
2973
+ def positional_param_types(method_def, type_vars: {})
2491
2974
  method_types = method_def.method_types
2492
2975
  return nil unless method_types.size == 1
2493
2976
 
@@ -2496,7 +2979,7 @@ module Rigor
2496
2979
 
2497
2980
  (func.required_positionals + func.optional_positionals).map do |param|
2498
2981
  Inference::RbsTypeTranslator.translate(
2499
- param.type, self_type: nil, instance_type: nil, type_vars: {}
2982
+ param.type, self_type: nil, instance_type: nil, type_vars: type_vars
2500
2983
  )
2501
2984
  rescue StandardError
2502
2985
  nil