rigortype 0.2.9 → 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 (184) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/docs/handbook/03-narrowing.md +2 -1
  4. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  5. data/docs/handbook/06-classes.md +18 -9
  6. data/docs/handbook/08-understanding-errors.md +1 -0
  7. data/docs/handbook/11-sig-gen.md +11 -1
  8. data/docs/handbook/appendix-mypy.md +4 -3
  9. data/docs/install.md +14 -6
  10. data/docs/manual/01-installation.md +98 -4
  11. data/docs/manual/02-cli-reference.md +49 -12
  12. data/docs/manual/03-configuration.md +34 -2
  13. data/docs/manual/04-diagnostics.md +23 -0
  14. data/docs/manual/07-plugins.md +4 -4
  15. data/docs/manual/09-editor-integration.md +4 -4
  16. data/docs/manual/11-ci.md +51 -0
  17. data/docs/manual/12-caching.md +65 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  19. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  20. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  21. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  23. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  24. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  25. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  26. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  27. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  28. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  29. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  30. data/lib/rigor/analysis/check_rules.rb +557 -95
  31. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/incremental_session.rb +456 -51
  34. data/lib/rigor/analysis/path_expansion.rb +42 -0
  35. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  36. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  37. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  38. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  39. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  40. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  41. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  42. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  43. data/lib/rigor/analysis/runner.rb +343 -68
  44. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  45. data/lib/rigor/analysis/worker_session.rb +30 -2
  46. data/lib/rigor/bleeding_edge.rb +37 -8
  47. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  48. data/lib/rigor/cache/descriptor.rb +88 -28
  49. data/lib/rigor/cache/file_digest.rb +158 -0
  50. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  51. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  52. data/lib/rigor/cache/store.rb +33 -3
  53. data/lib/rigor/ci_detector.rb +90 -0
  54. data/lib/rigor/cli/annotate_command.rb +15 -9
  55. data/lib/rigor/cli/baseline_command.rb +4 -1
  56. data/lib/rigor/cli/check_command.rb +212 -27
  57. data/lib/rigor/cli/coverage_command.rb +6 -1
  58. data/lib/rigor/cli/docs_command.rb +0 -30
  59. data/lib/rigor/cli/doctor_command.rb +163 -23
  60. data/lib/rigor/cli/lsp_command.rb +5 -0
  61. data/lib/rigor/cli/mcp_command.rb +5 -0
  62. data/lib/rigor/cli/plugins_command.rb +36 -6
  63. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  64. data/lib/rigor/cli/probe_environment.rb +85 -0
  65. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  66. data/lib/rigor/cli/skill_command.rb +1 -32
  67. data/lib/rigor/cli/trace_command.rb +5 -6
  68. data/lib/rigor/cli/type_of_command.rb +9 -8
  69. data/lib/rigor/cli/type_scan_command.rb +8 -8
  70. data/lib/rigor/cli.rb +15 -4
  71. data/lib/rigor/config_audit.rb +48 -2
  72. data/lib/rigor/configuration/severity_profile.rb +31 -0
  73. data/lib/rigor/configuration.rb +156 -8
  74. data/lib/rigor/environment/default_libraries.rb +35 -0
  75. data/lib/rigor/environment/rbs_loader.rb +164 -5
  76. data/lib/rigor/environment.rb +19 -27
  77. data/lib/rigor/inference/budget_trace.rb +77 -2
  78. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  79. data/lib/rigor/inference/def_handle.rb +23 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  81. data/lib/rigor/inference/def_return_typer.rb +2 -1
  82. data/lib/rigor/inference/expression_typer.rb +361 -113
  83. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  86. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  87. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  88. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  89. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  91. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  92. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  93. data/lib/rigor/inference/mutation_widening.rb +42 -8
  94. data/lib/rigor/inference/narrowing.rb +127 -17
  95. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  97. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  98. data/lib/rigor/inference/scope_indexer.rb +451 -36
  99. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  100. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  101. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  102. data/lib/rigor/inference/void_origin.rb +25 -0
  103. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  104. data/lib/rigor/language_server/completion_provider.rb +2 -1
  105. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  106. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  107. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  108. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  109. data/lib/rigor/plugin/base.rb +9 -22
  110. data/lib/rigor/plugin/inflector.rb +12 -3
  111. data/lib/rigor/plugin/io_boundary.rb +5 -1
  112. data/lib/rigor/plugin/isolation.rb +81 -11
  113. data/lib/rigor/plugin/load_error.rb +10 -1
  114. data/lib/rigor/plugin/loader.rb +96 -14
  115. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  116. data/lib/rigor/plugin/registry.rb +32 -23
  117. data/lib/rigor/plugin.rb +26 -0
  118. data/lib/rigor/protection/mutator.rb +3 -2
  119. data/lib/rigor/reflection.rb +64 -0
  120. data/lib/rigor/runtime/jit.rb +128 -0
  121. data/lib/rigor/scope/discovery_index.rb +12 -2
  122. data/lib/rigor/scope.rb +122 -28
  123. data/lib/rigor/sig_gen/classification.rb +5 -1
  124. data/lib/rigor/sig_gen/generator.rb +38 -6
  125. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  126. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  127. data/lib/rigor/sig_gen/renderer.rb +10 -0
  128. data/lib/rigor/sig_gen/write_result.rb +9 -4
  129. data/lib/rigor/sig_gen/writer.rb +24 -2
  130. data/lib/rigor/source/node_children.rb +116 -0
  131. data/lib/rigor/source/node_locator.rb +3 -1
  132. data/lib/rigor/source/node_walker.rb +4 -2
  133. data/lib/rigor/source.rb +1 -0
  134. data/lib/rigor/type/difference.rb +28 -24
  135. data/lib/rigor/type/hash_shape.rb +34 -10
  136. data/lib/rigor/version.rb +1 -1
  137. data/lib/rigortype.rb +24 -0
  138. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  139. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  141. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  142. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  143. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  146. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  147. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  148. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  149. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  150. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  151. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  152. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  153. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  154. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  157. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  158. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  159. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  160. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  162. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  164. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  166. data/sig/prism_node_children.rbs +9 -0
  167. data/sig/rigor/inference.rbs +7 -0
  168. data/sig/rigor/plugin/base.rbs +2 -3
  169. data/sig/rigor/reflection.rbs +1 -0
  170. data/sig/rigor/scope.rbs +12 -1
  171. data/skills/rigor-ci-setup/SKILL.md +10 -0
  172. data/skills/rigor-editor-setup/SKILL.md +11 -0
  173. data/skills/rigor-next-steps/SKILL.md +23 -2
  174. data/skills/rigor-plugin-author/SKILL.md +3 -3
  175. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  176. data/skills/rigor-plugin-review/SKILL.md +2 -2
  177. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  178. data/skills/rigor-project-init/SKILL.md +29 -3
  179. data/skills/rigor-project-init/references/01-detect.md +1 -1
  180. data/skills/rigor-project-init/references/02-configure.md +39 -5
  181. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  182. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  183. metadata +42 -24
  184. data/lib/rigor/cli/ci_detector.rb +0 -89
data/docs/manual/11-ci.md CHANGED
@@ -246,6 +246,57 @@ updates:
246
246
  extra files — but Dependabot does not see a version inside a `run:`
247
247
  step, so updates to the pin are manual.
248
248
 
249
+ ## Persisting the analysis cache across runs
250
+
251
+ By default each CI run starts cold and re-analyses everything. The
252
+ templates leave it that way on purpose — cold runs are simple,
253
+ deterministic, and fast enough for most projects. When the Rigor
254
+ job's runtime starts to matter (a large codebase, many plugins),
255
+ persist [the analysis cache](12-caching.md) between runs.
256
+
257
+ **Restore and save the cache directory** with your CI's cache
258
+ facility, keyed on anything that changes the analysis wholesale
259
+ (the Rigor version / lockfile). That is the whole setup: Rigor
260
+ detects CI and switches its cache freshness check from stat
261
+ metadata (which a fresh checkout regenerates, and which would
262
+ otherwise force parts of the restored cache to recompute every
263
+ run) to content hashes, which are identical across checkouts.
264
+ Details: [How a file is checked for changes](12-caching.md#how-a-file-is-checked-for-changes).
265
+
266
+ GitHub Actions:
267
+
268
+ ```yaml
269
+ - uses: actions/cache@v4
270
+ with:
271
+ path: .rigor/cache
272
+ key: rigor-cache-${{ runner.os }}-${{ hashFiles('.github/rigor/Gemfile.lock') }}-${{ github.sha }}
273
+ restore-keys: rigor-cache-${{ runner.os }}-${{ hashFiles('.github/rigor/Gemfile.lock') }}-
274
+ - run: bundle exec rigor check
275
+ ```
276
+
277
+ GitLab CI:
278
+
279
+ ```yaml
280
+ rigor:
281
+ cache:
282
+ key: rigor-cache
283
+ paths:
284
+ - .rigor/cache
285
+ ```
286
+
287
+ Two refinements:
288
+
289
+ - A **self-hosted runner that reuses its workspace** keeps stable
290
+ stat metadata, so the faster stat check works there — opt back
291
+ into it with `cache.validation: stat`.
292
+ - On Rigor versions before the `auto` default, set
293
+ `RIGOR_STRICT_VALIDATION: "1"` on the job explicitly.
294
+
295
+ The cache is content-keyed and self-invalidating — a stale or
296
+ corrupt restore degrades to a recompute, never a wrong result. If
297
+ you would rather guarantee every run is independent, pass
298
+ `--no-cache` instead and skip this section.
299
+
249
300
  ## Container image
250
301
 
251
302
  A standalone image is published to GHCR with Ruby 4.0 and Rigor
@@ -38,6 +38,49 @@ The cache is also schema-versioned: after a Rigor upgrade that
38
38
  changes the cache format, the stale cache is purged on the
39
39
  first writable run.
40
40
 
41
+ ## How a file is checked for changes
42
+
43
+ To decide whether a cached entry is still valid, Rigor needs to
44
+ know which of your files changed since the entry was written. By
45
+ default it checks each file's **stat metadata** first — size,
46
+ nanosecond modification and change timestamps, and inode — and
47
+ only re-hashes a file's content when that metadata moved. On a
48
+ large project an unchanged run then reads *zero* content bytes to
49
+ validate the cache, instead of re-hashing every file.
50
+
51
+ The content hash stays the sole authority on whether a file
52
+ actually changed: the stat check only decides whether the hash
53
+ needs recomputing. A file that was merely `touch`ed (new
54
+ timestamp, identical content) is re-hashed once and correctly
55
+ found unchanged. Editing a file always moves its timestamps, so
56
+ an edit is never missed.
57
+
58
+ The stat check is skipped where it cannot work: the default
59
+ setting is `validation: auto`, which behaves as `stat` on your
60
+ machine and switches to hashing every file (`digest`) when a CI
61
+ environment is detected. CI is the common case of untrustworthy
62
+ stat metadata — a fresh checkout regenerates every timestamp and
63
+ inode, so a cache restored across CI runs (for example with
64
+ `actions/cache`) never passes the stat check, and parts of it
65
+ (plugin watch-glob entries, which are stat-signature only) would
66
+ be recomputed on every run. Content hashes are identical across
67
+ checkouts, so under `digest` the restored cache simply hits.
68
+
69
+ The setting can be forced either way:
70
+
71
+ ```yaml
72
+ cache:
73
+ validation: digest # hash always — for any filesystem whose
74
+ # timestamps or inodes cannot be trusted
75
+ # validation: stat # stat always — e.g. a self-hosted CI
76
+ # runner that reuses its workspace, where
77
+ # stat metadata IS stable across runs
78
+ ```
79
+
80
+ For a single run, `RIGOR_STRICT_VALIDATION=1` forces `digest`
81
+ and wins over the config key; `RIGOR_CI_DETECT=0` turns off the
82
+ CI detection that `auto` relies on.
83
+
41
84
  ## Controlling the cache
42
85
 
43
86
  | Flag | Effect |
@@ -90,6 +133,28 @@ gate, `rigor check --verify-incremental`, asserts this on every
90
133
  build: it runs the incremental analyzer and a full analysis and
91
134
  fails if they disagree on a single diagnostic.
92
135
 
136
+ Rigor is also precise about *what* an edit changed. Re-checking a
137
+ file that many others depend on — a base class, a widely-used
138
+ concern, a shared utility — used to re-check every dependent.
139
+ Now, if your edit did not change the file's declaration shape
140
+ (the signatures of its methods, its superclass and includes) or
141
+ the types its methods return, the files that only depend on those
142
+ things are left untouched: a comment, a formatting change, or an
143
+ internal refactor that preserves every method's return type stops
144
+ at the edited file. An edit that *does* change a return type or a
145
+ signature still propagates to the dependents that consume it, so
146
+ the result stays identical to a full run.
147
+
148
+ Types contributed by plugins (a Sorbet signature, an
149
+ ActiveRecord column type, a dry-types alias) are validated too:
150
+ if a plugin's cross-file contributions change between runs, the
151
+ snapshot is dropped and the next run is a full one, so a plugin
152
+ edit can never leave a dependent stale.
153
+
154
+ An `--incremental` re-check honours `--workers=N` (and
155
+ `RIGOR_RACTOR_WORKERS` / `parallel.workers:`), analysing the
156
+ affected files in parallel just as a full run does.
157
+
93
158
  The snapshot lives under the cache directory (`.rigor/cache`)
94
159
  and is keyed by a fingerprint of your configuration, your locked
95
160
  gems, your project's own `sig/` RBS, and the Rigor version.
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../../source/node_children"
6
+ require_relative "inferred_param_guard"
7
+
5
8
  module Rigor
6
9
  module Analysis
7
10
  module CheckRules
@@ -81,7 +84,7 @@ module Rigor
81
84
  visit(node) if conditional_node?(node) && !in_loop_or_block
82
85
 
83
86
  child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
84
- node.compact_child_nodes.each { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
87
+ node.rigor_each_child { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
85
88
  end
86
89
 
87
90
  def conditional_node?(node)
@@ -100,6 +103,11 @@ module Rigor
100
103
  scope = @scope_index[node]
101
104
  return if scope.nil?
102
105
 
106
+ # ADR-67 WD6b — a predicate rooted at an inferred parameter (`opts.key?(:x)`) folds to a constant only
107
+ # because the seed's lower-bound type pinned it; a wider real caller would not. Declining preserves
108
+ # the precision-additive contract.
109
+ return if InferredParamGuard.rooted?(predicate, scope)
110
+
103
111
  predicate_type = scope.type_of(predicate)
104
112
  return unless predicate_type.is_a?(Type::Constant)
105
113
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../../source/node_children"
6
+
5
7
  module Rigor
6
8
  module Analysis
7
9
  module CheckRules
@@ -65,7 +67,7 @@ module Rigor
65
67
  return unless node.is_a?(Prism::Node)
66
68
 
67
69
  collect_def_assignments(node) if node.is_a?(Prism::DefNode)
68
- node.compact_child_nodes.each { |child| walk_for_def_nodes(child) }
70
+ node.rigor_each_child { |child| walk_for_def_nodes(child) }
69
71
  end
70
72
 
71
73
  def collect_def_assignments(def_node)
@@ -92,7 +94,7 @@ module Rigor
92
94
  # / similar shapes don't trip the rule.
93
95
  accumulator << node.name if reading_assignment?(node)
94
96
 
95
- node.compact_child_nodes.each { |child| gather_read_names(child, accumulator) }
97
+ node.rigor_each_child { |child| gather_read_names(child, accumulator) }
96
98
  accumulator
97
99
  end
98
100
 
@@ -110,7 +112,7 @@ module Rigor
110
112
  # outer walker visits them separately.
111
113
  return accumulator if node.is_a?(Prism::DefNode) && !accumulator.last.equal?(node)
112
114
 
113
- node.compact_child_nodes.each { |child| gather_write_nodes(child, accumulator) }
115
+ node.rigor_each_child { |child| gather_write_nodes(child, accumulator) }
114
116
  accumulator
115
117
  end
116
118
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../source/node_children"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module CheckRules
10
+ # Walks a parse tree and collects every Hash-literal entry whose LITERAL key repeats an earlier
11
+ # literal key in the same literal. Ruby's runtime keeps the LAST entry silently (the interpreter
12
+ # itself only warns under `-w`), so the earlier value is dead — almost always a copy-paste or merge
13
+ # leftover. Modeled on PHPStan's `DuplicateKeysInLiteralArraysRule`.
14
+ #
15
+ # The collector is the read-side companion to the `flow.duplicate-hash-key` rule. The envelope is
16
+ # purely syntactic and deliberately narrow so it cannot fire on working code:
17
+ #
18
+ # - Only keys whose VALUE is pinned at parse time participate: symbols (including the `key:`
19
+ # shorthand and the `:key =>` hashrocket spelling of the same symbol), plain non-interpolated
20
+ # strings, integers, floats, and the `true` / `false` / `nil` literals. Any other key form —
21
+ # interpolated strings/symbols, constants, method calls, locals — is never compared and never
22
+ # fires.
23
+ # - Key spaces are tagged by literal kind, matching `Hash`'s `eql?`-based lookup: `:a` and `"a"`
24
+ # are different keys, and `1` and `1.0` are different keys (`1.eql?(1.0)` is false), so Integer
25
+ # and Float literals are never cross-compared.
26
+ # - A `**splat` element is itself never a key, and it does NOT rescue a literal duplicate around
27
+ # it: in `{ a: 1, **h, a: 2 }` the second `a:` overwrites whatever the earlier entries (literal
28
+ # or splatted) contributed, so the pair still collides and still fires.
29
+ # - Both `Prism::HashNode` (braced literals) and `Prism::KeywordHashNode` (bare keyword arguments,
30
+ # `m(a: 1, a: 2)` — Prism parses these with only a `-w`-level warning, same as braced literals)
31
+ # are scanned. Each literal is scanned independently; nested Hash literals are their own scope.
32
+ class DuplicateHashKeyCollector
33
+ # ADR-53 Track B — the node classes the shared {RuleWalk} dispatches to this collector. Prism node
34
+ # classes are leaves, so class-equality dispatch matches the legacy walk's `is_a?` gate. The
35
+ # collector needs no context gate: it is purely syntactic and fires wherever the full DFS reaches
36
+ # a Hash literal, loops and blocks included.
37
+ NODE_CLASSES = [Prism::HashNode, Prism::KeywordHashNode].freeze
38
+
39
+ # Returns `Array<{hash_node:, key_node:, first_key_node:, key_label:}>` — one entry per LATER
40
+ # occurrence of a repeated literal key (`key_node` is the repeat the diagnostic points at;
41
+ # `first_key_node` is the earliest occurrence its message references). Empty when the tree has no
42
+ # qualifying duplicates.
43
+ def initialize(scope_index)
44
+ @scope_index = scope_index
45
+ @results = []
46
+ end
47
+
48
+ # Legacy single-collector walk — kept as the oracle the ADR-53 Track B equivalence harness
49
+ # compares {RuleWalk} against.
50
+ def collect(root)
51
+ walk_for_hash_nodes(root)
52
+ @results.freeze
53
+ end
54
+
55
+ # {RuleWalk} entry point: the legacy walk's per-literal logic, invoked at every Hash literal under
56
+ # the shared traversal contract. The `context` is unused — this collector processes all literals.
57
+ def visit(hash_node, _context = nil)
58
+ collect_duplicate_keys(hash_node)
59
+ end
60
+
61
+ # The accumulated result, frozen the same way `#collect` returns it — used by {RuleWalk}-driven
62
+ # callers after the walk completes.
63
+ def results
64
+ @results.freeze
65
+ end
66
+
67
+ private
68
+
69
+ def walk_for_hash_nodes(node)
70
+ return unless node.is_a?(Prism::Node)
71
+
72
+ collect_duplicate_keys(node) if node.is_a?(Prism::HashNode) || node.is_a?(Prism::KeywordHashNode)
73
+ node.rigor_each_child { |child| walk_for_hash_nodes(child) }
74
+ end
75
+
76
+ def collect_duplicate_keys(hash_node)
77
+ seen = {}
78
+ hash_node.elements.each do |element|
79
+ # `**splat` elements (AssocSplatNode) contribute no literal key; they are skipped WITHOUT
80
+ # resetting `seen` — a splat between two identical literal keys does not rescue the collision.
81
+ next unless element.is_a?(Prism::AssocNode)
82
+
83
+ key = literal_key(element.key)
84
+ next if key.nil?
85
+
86
+ first = seen[key]
87
+ if first
88
+ @results << {
89
+ hash_node: hash_node,
90
+ key_node: element.key,
91
+ first_key_node: first,
92
+ key_label: key_label(element.key)
93
+ }
94
+ else
95
+ seen[key] = element.key
96
+ end
97
+ end
98
+ end
99
+
100
+ # Maps a key node to a comparable `[kind, value]` tag, or nil for any non-value-pinned key form.
101
+ # The kind tag keeps the literal spaces separate (`:a` ≠ `"a"`, `1` ≠ `1.0`); within a kind the
102
+ # parsed Ruby value is compared under Hash's own `eql?`/`hash` semantics, exactly reproducing the
103
+ # runtime collision.
104
+ def literal_key(key_node)
105
+ case key_node
106
+ when Prism::SymbolNode then [:symbol, key_node.unescaped]
107
+ when Prism::StringNode then [:string, key_node.unescaped]
108
+ when Prism::IntegerNode then [:integer, key_node.value]
109
+ when Prism::FloatNode then [:float, key_node.value]
110
+ when Prism::TrueNode then [:true_literal]
111
+ when Prism::FalseNode then [:false_literal]
112
+ when Prism::NilNode then [:nil_literal]
113
+ end
114
+ end
115
+
116
+ # Renders the key for the diagnostic message in its canonical Ruby spelling, so the `a:` shorthand
117
+ # and `:a =>` hashrocket occurrences of one symbol read as the same key.
118
+ def key_label(key_node)
119
+ case key_node
120
+ when Prism::SymbolNode then ":#{key_node.unescaped}"
121
+ when Prism::StringNode then key_node.unescaped.inspect
122
+ else key_node.slice
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-67 WD6b — the shared "is this node rooted at an inferred parameter?" predicate.
9
+ #
10
+ # When the `parameter_inference:` gate seeds an undeclared parameter from its call-site union, that type
11
+ # is a *lower bound* (only the resolved call sites contribute; an unseen / dynamic caller could pass any
12
+ # wider value). A diagnostic decided against a lower bound is a false positive by construction — the same
13
+ # reasoning that keeps ADR-67 WD1 non-negotiable at the parameter boundary. So every negative in-body
14
+ # rule must decline when the value it reasons about is (transitively) an inferred parameter.
15
+ #
16
+ # `rooted?` walks a receiver / argument / condition-subject node down to its syntactic root and reports
17
+ # whether that root is a pristine inferred-parameter local. It is purely syntactic — a bounded AST walk
18
+ # over `.receiver` — not a value-flow provenance channel (the mark stays on the direct parameter local
19
+ # in the scope; this reads the chain root at the firing site). It covers:
20
+ #
21
+ # - the direct receiver / subject: `param.foo`, `case param`
22
+ # - an indexed element: `param[i] - x` (the `-` receiver is the `[]` call whose root is `param`)
23
+ # - any method chain: `param.foo.bar`, `param.foo(arg).baz`, `param.key?(:x)`
24
+ #
25
+ # Every value derived from an inferred parameter by dispatch is itself a lower bound, so firing against it
26
+ # is an FP by the same construction. No-op on a normal `check` run — `Scope#inferred_param?` is false
27
+ # unless the gate seeded the table.
28
+ module InferredParamGuard
29
+ module_function
30
+
31
+ # A defensive depth cap against a pathological receiver chain (the walk is otherwise linear in chain
32
+ # length, which is tiny in practice).
33
+ MAX_DEPTH = 64
34
+
35
+ def rooted?(node, scope, depth = 0)
36
+ return false if node.nil? || depth > MAX_DEPTH
37
+
38
+ case node
39
+ when Prism::LocalVariableReadNode then scope.inferred_param?(node.name)
40
+ when Prism::CallNode then rooted?(node.receiver, scope, depth + 1)
41
+ # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when
42
+ # ANY operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`).
43
+ # Declining on the whole expression is FP-safe by the same lower-bound argument.
44
+ when Prism::RescueModifierNode
45
+ rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
46
+ when Prism::AndNode, Prism::OrNode
47
+ rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
48
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
49
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
50
+ else false
51
+ end
52
+ end
53
+
54
+ def last_statement(body)
55
+ body.is_a?(Prism::StatementsNode) ? body.body.last : body
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -78,7 +79,7 @@ module Rigor
78
79
  return
79
80
  end
80
81
 
81
- node.compact_child_nodes.each { |child| walk(child, qualified_prefix) }
82
+ node.rigor_each_child { |child| walk(child, qualified_prefix) }
82
83
  end
83
84
 
84
85
  def collect_def_writes(def_node, qualified_prefix)
@@ -95,7 +96,7 @@ module Rigor
95
96
  record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
96
97
  return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
97
98
 
98
- node.compact_child_nodes.each { |child| gather_writes(child, class_name) }
99
+ node.rigor_each_child { |child| gather_writes(child, class_name) }
99
100
  end
100
101
 
101
102
  def record_write(node, class_name)
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../source/node_children"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module CheckRules
10
+ # Walks a parse tree and collects every explicit `ReturnNode` that sits lexically inside the `ensure`
11
+ # clause of a `begin` / `def` / `class` body. In Ruby, `return` inside `ensure` silently discards
12
+ # both the method's in-flight return value and any in-flight exception — the classic footgun RuboCop
13
+ # ships as `Lint/EnsureReturn` and PHPStan as `OverwrittenExitPointByFinallyRule`.
14
+ #
15
+ # The check is purely syntactic (no type engine involved) with a frame-aware envelope so it only
16
+ # flags a `return` that actually belongs to the frame whose `ensure` it sits in:
17
+ #
18
+ # - A `return` inside a nested `def`, a lambda (`-> {}` / `lambda {}`), or a `define_method` block
19
+ # within the ensure body is skipped — that `return` exits the inner frame, not the one whose
20
+ # in-flight exception the ensure guards.
21
+ # - A `return` inside a *plain* block (`each do ... return ... end`) within the ensure body DOES
22
+ # belong to the enclosing method and fires.
23
+ # - A nested `begin ... ensure ... end` *inside* an ensure body is not descended into at its own
24
+ # `ensure` clause — that inner `EnsureNode` is visited separately (every `BeginNode` is), so each
25
+ # offending `return` is collected exactly once.
26
+ class ReturnInEnsureCollector
27
+ # ADR-53 Track B — the node classes the shared {RuleWalk} dispatches to this collector. Prism
28
+ # represents every construct that can carry an `ensure` clause — `begin ... end`, a `def` body with
29
+ # `rescue` / `ensure`, a class / module body with them — as a `BeginNode`, so one dispatch class
30
+ # covers all the syntactic hosts. No context gates: the footgun is frame-relative, not
31
+ # position-relative, so a `begin/ensure` inside a loop, block, or lambda is checked the same way.
32
+ NODE_CLASSES = [Prism::BeginNode].freeze
33
+
34
+ # Receiver-less calls whose attached block opens a new return frame: a `return` inside their block
35
+ # exits the lambda / defined method, not the method whose `ensure` we are scanning. `proc` is
36
+ # deliberately absent — `return` inside a `Proc` block returns from the enclosing method, so it
37
+ # stays in scope for the rule.
38
+ FRAME_BARRIER_CALL_NAMES = %i[lambda define_method].freeze
39
+
40
+ def initialize(scope_index)
41
+ @scope_index = scope_index
42
+ @results = []
43
+ end
44
+
45
+ # Legacy single-collector walk — the oracle the ADR-53 Track B equivalence harness compares
46
+ # {RuleWalk} against.
47
+ def collect(root)
48
+ walk_for_begin_nodes(root)
49
+ @results.freeze
50
+ end
51
+
52
+ # {RuleWalk} entry point: invoked at every `BeginNode` the shared traversal reaches. The `context`
53
+ # is unused — this collector checks every ensure clause.
54
+ def visit(begin_node, _context = nil)
55
+ collect_ensure_returns(begin_node)
56
+ end
57
+
58
+ # Returns `Array<{return_node:}>` — one entry per offending `return`, frozen the same way
59
+ # `#collect` returns it.
60
+ def results
61
+ @results.freeze
62
+ end
63
+
64
+ private
65
+
66
+ def walk_for_begin_nodes(node)
67
+ return unless node.is_a?(Prism::Node)
68
+
69
+ collect_ensure_returns(node) if node.is_a?(Prism::BeginNode)
70
+ node.rigor_each_child { |child| walk_for_begin_nodes(child) }
71
+ end
72
+
73
+ def collect_ensure_returns(begin_node)
74
+ ensure_clause = begin_node.ensure_clause
75
+ return if ensure_clause.nil?
76
+
77
+ gather_returns(ensure_clause.statements)
78
+ end
79
+
80
+ def gather_returns(node)
81
+ return unless node.is_a?(Prism::Node)
82
+
83
+ case node
84
+ when Prism::ReturnNode
85
+ @results << { return_node: node }
86
+ when Prism::DefNode, Prism::LambdaNode, Prism::EnsureNode
87
+ # Def / lambda open a new return frame — a `return` below them exits the nested frame, not the
88
+ # one whose ensure we are scanning. A nested `begin/ensure`'s own EnsureNode is skipped for a
89
+ # different reason: its BeginNode is visited separately, which collects those returns once —
90
+ # descending here would double-collect them.
91
+ return
92
+ when Prism::CallNode
93
+ return gather_returns_around_barrier_block(node) if frame_barrier_call?(node)
94
+ end
95
+
96
+ node.rigor_each_child { |child| gather_returns(child) }
97
+ end
98
+
99
+ # `lambda { ... }` / `define_method(:x) { ... }`: the receiver and arguments stay in the current
100
+ # frame (and may themselves contain an offending `return`), only the attached block opens a new
101
+ # one — descend into everything except the block.
102
+ def gather_returns_around_barrier_block(call_node)
103
+ barrier_block = call_node.block
104
+ call_node.rigor_each_child do |child|
105
+ gather_returns(child) unless child.equal?(barrier_block)
106
+ end
107
+ end
108
+
109
+ def frame_barrier_call?(call_node)
110
+ call_node.receiver.nil? &&
111
+ FRAME_BARRIER_CALL_NAMES.include?(call_node.name) &&
112
+ call_node.block.is_a?(Prism::BlockNode)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module CheckRules
6
+ # Canonical identifiers for each rule. Per ADR-8 § "Diagnostic ID family hierarchy", rule names are
7
+ # `family.rule-name` two-segment strings; the families group diagnostics by where they originate
8
+ # (`call.*` for call-site rules, `flow.*` for flow-analysis proofs, `assert.*` for runtime-assertion
9
+ # rules, `dump.*` for debug helpers, `def.*` for method-definition rules). Used by the configuration
10
+ # `disable:` list and the in-source `# rigor:disable <rule>` suppression comment system; new rules MUST
11
+ # register here so user configuration can refer to them.
12
+ #
13
+ # ADR-87 WD4 — this pure-data constant table is split out of the (engine-heavy) `check_rules.rb` so
14
+ # {Analysis::RuleCatalog} — which the CLI's JSON `evidence_tier` / `documentation_url` enrichment reads
15
+ # — can require it WITHOUT loading the inference engine. The boot-slimming hit path stays engine-free
16
+ # even when it formats JSON.
17
+ RULE_UNDEFINED_METHOD = "call.undefined-method"
18
+ RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
19
+ RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
20
+ RULE_WRONG_ARITY = "call.wrong-arity"
21
+ RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
22
+ RULE_NIL_RECEIVER = "call.possible-nil-receiver"
23
+ RULE_RAISE_NON_EXCEPTION = "call.raise-non-exception"
24
+ RULE_DUMP_TYPE = "dump.type"
25
+ RULE_ASSERT_TYPE = "assert.type-mismatch"
26
+ RULE_ALWAYS_RAISES = "flow.always-raises"
27
+ RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
28
+ RULE_RETURN_TYPE = "def.return-type-mismatch"
29
+ RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
30
+ RULE_OVERRIDE_VISIBILITY_REDUCED = "def.override-visibility-reduced"
31
+ RULE_OVERRIDE_RETURN_WIDENED = "def.override-return-widened"
32
+ RULE_OVERRIDE_PARAM_NARROWED = "def.override-param-narrowed"
33
+ RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
34
+ RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
35
+ RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
36
+ RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
37
+ RULE_DUPLICATE_HASH_KEY = "flow.duplicate-hash-key"
38
+ RULE_RETURN_IN_ENSURE = "flow.return-in-ensure"
39
+ RULE_SHADOWED_RESCUE_CLAUSE = "flow.shadowed-rescue-clause"
40
+ RULE_SUPPRESSION_UNKNOWN_RULE = "suppression.unknown-rule"
41
+ RULE_SUPPRESSION_EMPTY = "suppression.empty"
42
+ RULE_SUPPRESSION_UNKNOWN_MARKER = "suppression.unknown-marker"
43
+ # ADR-100 — the first `static.value-use.*` id: a value recovered from an author-declared `-> void`
44
+ # return, used in value context. Authored `:warning`, resolved `:off` by every profile and promoted to
45
+ # `:warning` only by the `use-of-void-value` bleeding-edge feature.
46
+ RULE_VALUE_USE_VOID = "static.value-use.void"
47
+
48
+ ALL_RULES = [
49
+ RULE_UNDEFINED_METHOD,
50
+ RULE_SELF_UNDEFINED_METHOD,
51
+ RULE_UNRESOLVED_TOPLEVEL,
52
+ RULE_WRONG_ARITY,
53
+ RULE_ARGUMENT_TYPE,
54
+ RULE_NIL_RECEIVER,
55
+ RULE_RAISE_NON_EXCEPTION,
56
+ RULE_DUMP_TYPE,
57
+ RULE_ASSERT_TYPE,
58
+ RULE_ALWAYS_RAISES,
59
+ RULE_UNREACHABLE_BRANCH,
60
+ RULE_DEAD_ASSIGNMENT,
61
+ RULE_ALWAYS_TRUTHY_CONDITION,
62
+ RULE_UNREACHABLE_CLAUSE,
63
+ RULE_DUPLICATE_HASH_KEY,
64
+ RULE_RETURN_IN_ENSURE,
65
+ RULE_SHADOWED_RESCUE_CLAUSE,
66
+ RULE_RETURN_TYPE,
67
+ RULE_VISIBILITY_MISMATCH,
68
+ RULE_OVERRIDE_VISIBILITY_REDUCED,
69
+ RULE_OVERRIDE_RETURN_WIDENED,
70
+ RULE_OVERRIDE_PARAM_NARROWED,
71
+ RULE_IVAR_WRITE_MISMATCH,
72
+ RULE_SUPPRESSION_UNKNOWN_RULE,
73
+ RULE_SUPPRESSION_EMPTY,
74
+ RULE_SUPPRESSION_UNKNOWN_MARKER,
75
+ RULE_VALUE_USE_VOID
76
+ ].freeze
77
+
78
+ # Backward-compat alias table (ADR-8 § "Backward compatibility"). Existing user code with
79
+ # `# rigor:disable undefined-method` / `disable: [undefined-method]` keeps working — the legacy
80
+ # unprefixed identifiers map to their canonical `family.rule-name` form here. Removing the aliases is a
81
+ # future ADR once user code has migrated; until then, both spellings resolve identically.
82
+ LEGACY_RULE_ALIASES = {
83
+ "undefined-method" => RULE_UNDEFINED_METHOD,
84
+ "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
85
+ "wrong-arity" => RULE_WRONG_ARITY,
86
+ "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
87
+ "possible-nil-receiver" => RULE_NIL_RECEIVER,
88
+ "raise-non-exception" => RULE_RAISE_NON_EXCEPTION,
89
+ "dump-type" => RULE_DUMP_TYPE,
90
+ "assert-type" => RULE_ASSERT_TYPE,
91
+ "always-raises" => RULE_ALWAYS_RAISES,
92
+ "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
93
+ "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
94
+ "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
95
+ "dead-assignment" => RULE_DEAD_ASSIGNMENT,
96
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
97
+ "unreachable-clause" => RULE_UNREACHABLE_CLAUSE,
98
+ "duplicate-hash-key" => RULE_DUPLICATE_HASH_KEY,
99
+ "return-in-ensure" => RULE_RETURN_IN_ENSURE,
100
+ "shadowed-rescue-clause" => RULE_SHADOWED_RESCUE_CLAUSE
101
+ }.freeze
102
+
103
+ # Family wildcard — a `<family>` token in a suppression comment or `disable:` list disables every rule
104
+ # whose canonical id starts with `<family>.`. Per ADR-8 § "1".
105
+ RULE_FAMILIES = %w[call flow assert dump def suppression static].freeze
106
+
107
+ # Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and
108
+ # reporter-level diagnostics such as `rbs_extended.unsatisfied-conformance`,
109
+ # `dynamic.dependency-source.*`, `rbs.coverage.*`, `pre-eval.parse-error`), plus the `plugin.` prefix
110
+ # reserved for plugin-produced identifiers. `suppression.unknown-rule` treats a dotted token whose
111
+ # first segment appears here as KNOWN and stays silent: these ids are legitimate suppression /
112
+ # `severity_overrides:` vocabulary the light rule-id table cannot enumerate (plugins load dynamically;
113
+ # aggregator ids live in the engine-heavy runner), so under-warning is the FP-safe direction.
114
+ NON_CHECK_DIAGNOSTIC_FAMILIES = %w[rbs_extended dynamic rbs pre-eval plugin].freeze
115
+
116
+ # Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the `rule:` literals in
117
+ # `Analysis::Runner` / `Runner::DiagnosticAggregator`). A token equal to one of these is treated as
118
+ # known by `suppression.unknown-rule` even though it carries no family prefix; extend the list when
119
+ # the runner grows a new bare id.
120
+ NON_CHECK_DIAGNOSTIC_IDS = %w[
121
+ configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
122
+ ].freeze
123
+ end
124
+ end
125
+ end