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