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
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Allocation-free replacement for the `node.compact_child_nodes.each { … }` idiom.
8
+ #
9
+ # `Prism::Node#compact_child_nodes` allocates a fresh Array on every call — for the ~43 leaf node classes
10
+ # (`IntegerNode`, `LocalVariableReadNode`, `NilNode`, …) it is literally `def compact_child_nodes; []; end`.
11
+ # Rigor's tree walkers call it unconditionally on every node of every walk, so one full-tree walk allocates one
12
+ # Array per node visited. On leaf-heavy sources (a Ragel-generated parser has hundreds of thousands of
13
+ # integer-literal leaves) these throwaway arrays are the single largest allocation source in a run — over half
14
+ # of all allocations on mail's `lib`.
15
+ #
16
+ # Loading this file compiles a `#rigor_each_child` method onto every `Prism::*Node` class (additive reopening of
17
+ # a dependency's class, the `Cache::RbsEnvironmentMarshalPatch` precedent; the `rigor_` prefix keeps it
18
+ # collision-free). It yields the same child nodes, in the same order, without materialising the intermediate
19
+ # array: each child-bearing field is read directly in field declaration order — exactly the order
20
+ # `compact_child_nodes` emits — and a `NodeListField`'s already-materialised array is iterated in place (the
21
+ # reader returns the stored array, not a copy). Nil optional children and nil list elements are skipped,
22
+ # mirroring `compact_child_nodes`'s "compact" semantics. Leaf classes compile to an empty method.
23
+ #
24
+ # A method compiled onto the node class is the fastest dispatch available for this shape — one virtual send on
25
+ # the receiver, the same mechanism `compact_child_nodes` itself uses, with zero allocation. The reflective
26
+ # alternatives were measured and rejected: a `public_send`-per-field loop is ~45 % *slower* than
27
+ # `compact_child_nodes` on a full-tree walk (dynamic dispatch per field swamps the allocation win), and a
28
+ # central generated-method table dispatched via `Module#send` + a per-class Hash lookup still regressed
29
+ # Rails-corpus wall ~5 % under YJIT. Walkers therefore call `node.rigor_each_child { … }` directly.
30
+ #
31
+ # The field map is derived once at load from `Prism::Reflection`, so it tracks whatever `prism` version resolves
32
+ # at runtime (ADR-79) instead of a hand-maintained table. `spec/rigor/source/node_children_spec.rb` is the
33
+ # binding contract: over a corpus of real source it asserts — for every node reached — that both
34
+ # `#rigor_each_child` and {each_child} yield output element-for-element identical (object identity and order) to
35
+ # `compact_child_nodes`.
36
+ module NodeChildren
37
+ module_function
38
+
39
+ # The concrete `Prism::*Node` classes (`< Prism::Node`).
40
+ NODE_CLASSES =
41
+ Prism.constants.grep(/Node\z/).filter_map do |name|
42
+ const = Prism.const_get(name)
43
+ const if const.is_a?(Class) && const < Prism::Node
44
+ end.freeze
45
+
46
+ # Shared frozen empty entry for leaf classes. Identity (`equal?`) distinguishes a leaf entry from a
47
+ # childless-in-practice non-leaf.
48
+ EMPTY = [].freeze
49
+
50
+ # node class => frozen Array of `[reader_symbol, :node | :node_optional | :list]` pairs, in field declaration
51
+ # order (the order `compact_child_nodes` emits its children). Leaf classes map to {EMPTY}. This is the source
52
+ # of truth the per-class methods are compiled from, exposed for introspection and the equivalence spec.
53
+ CHILD_READERS =
54
+ NODE_CLASSES.each_with_object({}) do |klass, map|
55
+ pairs = Prism::Reflection.fields_for(klass).filter_map do |field|
56
+ case field
57
+ when Prism::Reflection::OptionalNodeField
58
+ [field.name, :node_optional].freeze
59
+ when Prism::Reflection::NodeField
60
+ [field.name, :node].freeze
61
+ when Prism::Reflection::NodeListField
62
+ [field.name, :list].freeze
63
+ end
64
+ end
65
+ map[klass] = pairs.empty? ? EMPTY : pairs.freeze
66
+ end.freeze
67
+
68
+ # The leaf classes — no child-bearing field, so `compact_child_nodes` is always `[]` and `#rigor_each_child`
69
+ # compiles to an empty method. Exposed for the equivalence spec and introspection.
70
+ LEAF_CLASSES = NODE_CLASSES.select { |klass| CHILD_READERS[klass].equal?(EMPTY) }.to_set.freeze
71
+
72
+ # Codegen (not per-call reflection) so iteration reads fields directly at hand-written speed — see the module
73
+ # comment for the measurements ruling out the reflective forms. Each statement mirrors `compact_child_nodes`'s
74
+ # own form for that field kind exactly: a required `NodeField` is yielded unconditionally (`compact <<
75
+ # predicate`), an `OptionalNodeField` behind a truthiness guard (`compact << receiver if receiver`), and a
76
+ # `NodeListField`'s stored array is iterated unfiltered (`compact.concat(requireds)`) — no extra nil checks
77
+ # beyond what Prism itself performs. The explicit `self.` receiver keeps a hypothetical future field named
78
+ # `child` from parsing as the just-assigned local. For a `CallNode` (three optional-node fields) the emitted
79
+ # source is:
80
+ #
81
+ # def rigor_each_child
82
+ # child = self.receiver
83
+ # yield child if child
84
+ # child = self.arguments
85
+ # yield child if child
86
+ # child = self.block
87
+ # yield child if child
88
+ # end
89
+ NODE_CLASSES.each do |klass|
90
+ statements = CHILD_READERS[klass].map do |reader, kind|
91
+ case kind
92
+ when :list then "self.#{reader}.each { |child| yield child }"
93
+ when :node_optional then "child = self.#{reader}\n yield child if child"
94
+ else "yield self.#{reader}"
95
+ end
96
+ end
97
+ klass.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
98
+ def rigor_each_child
99
+ #{statements.join("\n ")}
100
+ end
101
+ RUBY
102
+ end
103
+
104
+ # Yield each direct child `Prism::Node` of `node` in `compact_child_nodes` order, without allocating an
105
+ # intermediate array. Nil / non-node input yields nothing (an optional field can be nil, so a caller may pass
106
+ # e.g. `node.body`). Pure and re-entrant; `break` / `next` / `return` in the block behave exactly as they
107
+ # would with `compact_child_nodes.each`. Walkers holding a known `Prism::Node` call `#rigor_each_child`
108
+ # directly; this wrapper is the nil-tolerant entry.
109
+ #
110
+ # @yieldparam child [Prism::Node]
111
+ def each_child(node, &)
112
+ node.rigor_each_child(&) if node.is_a?(Prism::Node)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "node_children"
6
+
5
7
  module Rigor
6
8
  module Source
7
9
  # Locates the deepest Prism AST node enclosing a given source position.
@@ -83,7 +85,7 @@ module Rigor
83
85
  return nil unless node.is_a?(Prism::Node)
84
86
  return nil unless contains?(node, offset)
85
87
 
86
- node.compact_child_nodes.each do |child|
88
+ node.rigor_each_child do |child|
87
89
  deeper = descend(child, offset)
88
90
  return deeper if deeper
89
91
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "node_children"
6
+
5
7
  module Rigor
6
8
  module Source
7
9
  # Yields every `Prism::Node` reachable from a root in DFS pre-order.
@@ -28,7 +30,7 @@ module Rigor
28
30
  return unless node.is_a?(Prism::Node)
29
31
 
30
32
  yield node
31
- node.compact_child_nodes.each { |child| walk(child, &) }
33
+ node.rigor_each_child { |child| walk(child, &) }
32
34
  end
33
35
 
34
36
  # Like {.each}, but also yields the node's lexical ancestor chain (outermost first, EXCLUDING the node
@@ -51,7 +53,7 @@ module Rigor
51
53
 
52
54
  block.call(node, ancestors)
53
55
  ancestors.push(node)
54
- node.compact_child_nodes.each { |child| walk_with_ancestors(child, ancestors, &block) }
56
+ node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
55
57
  ancestors.pop
56
58
  end
57
59
  end
data/lib/rigor/source.rb CHANGED
@@ -10,6 +10,7 @@ module Rigor
10
10
  end
11
11
  end
12
12
 
13
+ require_relative "source/node_children"
13
14
  require_relative "source/node_locator"
14
15
  require_relative "source/node_walker"
15
16
  require_relative "source/literals"
@@ -48,6 +48,30 @@ module Rigor
48
48
  base.erase_to_rbs
49
49
  end
50
50
 
51
+ # True when `removed` is the *empty witness* of `base` — the single value whose removal makes this
52
+ # difference one of the catalogued `non-empty-*` / `non-zero-int` refinements (`""` for String, `0`
53
+ # for Integer, `[]` for Array, `{}` for Hash).
54
+ #
55
+ # This is the shared recogniser for the whole engine: the display side (`canonical_name` below),
56
+ # `ShapeDispatch`'s refinement-aware projections, and `MutationWidening`'s invalidation arm all ask
57
+ # this one question, so the recognised set cannot drift between them.
58
+ def removes_empty_witness?
59
+ return false unless base.is_a?(Nominal)
60
+
61
+ predicate = EMPTY_WITNESS_PREDICATES[base.class_name]
62
+ !!predicate && predicate.call(removed)
63
+ end
64
+
65
+ EMPTY_WITNESS_PREDICATES = {
66
+ "String" => ->(removed) { removed.is_a?(Constant) && removed.value == "" },
67
+ "Integer" => lambda { |removed|
68
+ removed.is_a?(Constant) && removed.value.is_a?(Integer) && removed.value.zero?
69
+ },
70
+ "Array" => ->(removed) { removed.is_a?(Tuple) && removed.elements.empty? },
71
+ "Hash" => ->(removed) { removed.is_a?(HashShape) && removed.pairs.empty? }
72
+ }.freeze
73
+ private_constant :EMPTY_WITNESS_PREDICATES
74
+
51
75
  def top
52
76
  Trinary.no
53
77
  end
@@ -80,47 +104,27 @@ module Rigor
80
104
  # The recognised set is kept in sync with the imported-built-in catalogue
81
105
  # ([`imported-built-in-types.md`](docs/type-specification/imported-built-in-types.md)).
82
106
  def canonical_name
83
- return nil unless base.is_a?(Nominal)
107
+ return nil unless removes_empty_witness?
84
108
 
85
- send(CANONICAL_HANDLERS[base.class_name] || :no_canonical_name)
109
+ send(CANONICAL_HANDLERS[base.class_name])
86
110
  end
87
111
 
88
112
  CANONICAL_HANDLERS = {
89
113
  "String" => :string_canonical_name,
90
114
  "Integer" => :integer_canonical_name,
91
- "Array" => :array_canonical_name_if_empty,
92
- "Hash" => :hash_canonical_name_if_empty
115
+ "Array" => :array_canonical_name,
116
+ "Hash" => :hash_canonical_name
93
117
  }.freeze
94
118
  private_constant :CANONICAL_HANDLERS
95
119
 
96
- def no_canonical_name
97
- nil
98
- end
99
-
100
120
  def string_canonical_name
101
- return nil unless removed.is_a?(Constant) && removed.value == ""
102
-
103
121
  "non-empty-string"
104
122
  end
105
123
 
106
124
  def integer_canonical_name
107
- return nil unless removed.is_a?(Constant) && removed.value.is_a?(Integer) && removed.value.zero?
108
-
109
125
  "non-zero-int"
110
126
  end
111
127
 
112
- def array_canonical_name_if_empty
113
- return nil unless removed.is_a?(Tuple) && removed.elements.empty?
114
-
115
- array_canonical_name
116
- end
117
-
118
- def hash_canonical_name_if_empty
119
- return nil unless removed.is_a?(HashShape) && removed.pairs.empty?
120
-
121
- hash_canonical_name
122
- end
123
-
124
128
  def array_canonical_name
125
129
  elem = base.type_args.first
126
130
  return "non-empty-array" if elem.nil?
@@ -11,9 +11,12 @@ module Rigor
11
11
  # inhabit the corresponding value types. RBS records correspond to the exact closed subset; Rigor
12
12
  # extends that carrier with optional keys, read-only entry views, and an open/closed extra-key policy.
13
13
  #
14
- # Keys are restricted to Symbol and String values. Exact closed symbol-keyed shapes erase to the RBS
15
- # record syntax `{ a: Integer, ?b: String }`; all other shapes degrade to `Hash[K, V]` or raw `Hash`
16
- # when no useful bounds are available.
14
+ # Keys are restricted to value-pinned scalar literals: Symbol, String, Integer, Float, and the
15
+ # `true` / `false` / `nil` singletons. Exact closed symbol-keyed shapes erase to the RBS record
16
+ # syntax `{ a: Integer, ?b: String }`; all other shapes degrade to `Hash[K, V]` or raw `Hash` when
17
+ # no useful bounds are available. Key identity follows Ruby's own `Hash` `eql?` semantics because
18
+ # the pairs are stored in a native Ruby Hash — `1` and `1.0` are DISTINCT keys (`1.eql?(1.0)` is
19
+ # false), while `1.0` and `1.00` are the same key.
17
20
  #
18
21
  # Equality and hashing are structural over the (key -> Rigor::Type) pair set and policy fields. Hash
19
22
  # insertion order is preserved by the underlying storage but does NOT affect equality (matching
@@ -22,7 +25,7 @@ module Rigor
22
25
  # See docs/type-specification/rbs-compatible-types.md (records) and
23
26
  # docs/type-specification/rigor-extensions.md (hash shape).
24
27
  class HashShape
25
- ALLOWED_KEY_CLASSES = [Symbol, String].freeze
28
+ ALLOWED_KEY_CLASSES = [Symbol, String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
26
29
  EXTRA_KEY_POLICIES = %i[open closed].freeze
27
30
  POLICY_KEYWORDS = %i[required_keys optional_keys read_only_keys extra_keys].freeze
28
31
  # A Symbol whose text matches renders as a bare RBS record key (`lang:`) in {#erase_to_rbs};
@@ -128,7 +131,8 @@ module Rigor
128
131
  pairs.each_key do |key|
129
132
  next if ALLOWED_KEY_CLASSES.any? { |klass| key.is_a?(klass) }
130
133
 
131
- raise ArgumentError, "HashShape keys must be Symbol or String, got #{key.class}"
134
+ raise ArgumentError,
135
+ "HashShape keys must be one of #{ALLOWED_KEY_CLASSES.join(', ')}, got #{key.class}"
132
136
  end
133
137
  end
134
138
 
@@ -190,17 +194,28 @@ module Rigor
190
194
  prefix = []
191
195
  prefix << "readonly" if read_only_key?(key)
192
196
  rendered_key = optional_key?(key) ? "?#{render_key(key)}" : render_key(key)
193
- prefix << "#{rendered_key}:"
197
+ prefix << "#{rendered_key}#{key_separator(key)}"
194
198
  "#{prefix.join(' ')} #{value.describe(verbosity)}"
195
199
  end
196
200
 
197
201
  # `describe` is a human-facing display contract (it feeds diagnostic messages, never a parser),
198
- # so it keeps the compact `"a":` form for a quoted key. The RBS-erasure path uses
199
- # {#erase_key_prefix} instead, which must emit a parseable key.
202
+ # so it keeps the compact `"a":` form for a quoted key. Non-(Symbol|String) scalar keys render in
203
+ # the natural hashrocket spelling (`1 => 2`, `1.0 => 4`, `nil => 0`) via {#key_separator}; their
204
+ # `inspect` is the canonical Ruby literal, so describe stays deterministic. The RBS-erasure path
205
+ # uses {#erase_key_prefix} instead, which must emit a parseable key.
200
206
  def render_key(key)
201
207
  case key
202
208
  when Symbol then key.to_s
203
- when String then key.inspect
209
+ else key.inspect
210
+ end
211
+ end
212
+
213
+ # Symbol / String keys keep the compact colon form (`a: 1`, `"k": 2`); every other scalar key uses
214
+ # the hashrocket (`1 => 2`) — the form the user would have to write in source.
215
+ def key_separator(key)
216
+ case key
217
+ when Symbol, String then ":"
218
+ else " =>"
204
219
  end
205
220
  end
206
221
 
@@ -230,8 +245,17 @@ module Rigor
230
245
  "Hash[#{key_type.erase_to_rbs}, #{value_type.erase_to_rbs}]"
231
246
  end
232
247
 
248
+ # Conservative per-key-class bound for the `Hash[K, V]` degradation. Symbol / String / Integer /
249
+ # Float keys widen to their class nominal; the `true` / `false` / `nil` singletons keep their
250
+ # literal carrier (the constant IS the class's whole value set, and RBS spells the literal — `nil`
251
+ # reads better than `NilClass`).
233
252
  def hash_erasure_key_type
234
- key_types = pairs.keys.map { |key| Type::Combinator.nominal_of(key.class) }
253
+ key_types = pairs.keys.map do |key|
254
+ case key
255
+ when true, false, nil then Type::Combinator.constant_of(key)
256
+ else Type::Combinator.nominal_of(key.class)
257
+ end
258
+ end
235
259
  Type::Combinator.union(*key_types)
236
260
  end
237
261
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.2.8"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/rigortype.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Nothing in an application should ever load Rigor: it is a command-line tool
4
+ # that reads a project from the outside, not a library (ADR-27). This file
5
+ # exists only because `Bundler.require` — the default for every Gemfile entry,
6
+ # and what Rails runs at boot — requires a gem by its *gem* name, `rigortype`,
7
+ # while the library entry point is `rigor.rb`.
8
+ #
9
+ # Without this file that call raises a bare `LoadError` naming `rigortype`, so
10
+ # a `bundle add rigortype` succeeds and the *next* `rails s` dies with no
11
+ # explanation of what went wrong or what to do instead. The file is a guardrail,
12
+ # not an entry point: it warns and defines nothing. `require "rigor"` remains
13
+ # the (analyzer-internal) way in.
14
+ warn(<<~MESSAGE)
15
+ rigortype: `require "rigortype"` does nothing — Rigor is a command-line tool, not a library.
16
+
17
+ This usually means Rigor is in your Gemfile, where `Bundler.require` loads it at boot. Rigor
18
+ analyses your project from the outside and runs on its own Ruby, so a Gemfile entry constrains
19
+ your application's Ruby and dependency resolution without giving you anything. Remove it and
20
+ install Rigor independently: https://github.com/rigortype/rigor/blob/master/docs/install.md
21
+
22
+ To keep the Gemfile entry anyway, mark it `gem "rigortype", require: false` — that silences
23
+ this warning, but the resolution constraints remain.
24
+ MESSAGE
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "channel_index"
@@ -76,7 +78,7 @@ module Rigor
76
78
  when Prism::ClassNode then visit_class(node, lexical_path, &)
77
79
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
78
80
  else
79
- node.compact_child_nodes.each { |child| walk_for_channels(child, lexical_path, &) }
81
+ node.rigor_each_child { |child| walk_for_channels(child, lexical_path, &) }
80
82
  end
81
83
  end
82
84
 
@@ -138,7 +140,7 @@ module Rigor
138
140
  end
139
141
  end
140
142
 
141
- node.compact_child_nodes.each do |child|
143
+ node.rigor_each_child do |child|
142
144
  names, dynamic = collect_stream_registrations(child, names: names, dynamic: dynamic)
143
145
  end
144
146
  [names, dynamic]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
  require "rigor/source/literals"
5
7
 
@@ -97,7 +99,7 @@ module Rigor
97
99
  when Prism::ClassNode then visit_class(node, lexical_path, &)
98
100
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
99
101
  else
100
- node.compact_child_nodes.each { |child| walk_for_mailers(child, lexical_path, &) }
102
+ node.rigor_each_child { |child| walk_for_mailers(child, lexical_path, &) }
101
103
  end
102
104
  end
103
105
 
@@ -124,7 +126,7 @@ module Rigor
124
126
  return [] if body.nil?
125
127
 
126
128
  names = []
127
- body.compact_child_nodes.each do |node|
129
+ body.rigor_each_child do |node|
128
130
  next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
129
131
 
130
132
  (node.arguments&.arguments || []).each do |arg|
@@ -158,7 +160,7 @@ module Rigor
158
160
  inner = local_name ? lexical_path + [local_name.delete_prefix("::")] : lexical_path
159
161
  collect_module_actions(node.body, inner, accumulator) if node.body
160
162
  else
161
- node.compact_child_nodes.each { |child| collect_module_actions(child, lexical_path, accumulator) }
163
+ node.rigor_each_child { |child| collect_module_actions(child, lexical_path, accumulator) }
162
164
  end
163
165
  end
164
166
 
@@ -231,7 +233,7 @@ module Rigor
231
233
  private_names = []
232
234
  callback_names = []
233
235
 
234
- body.compact_child_nodes.each do |node|
236
+ body.rigor_each_child do |node|
235
237
  next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
236
238
 
237
239
  args = (node.arguments&.arguments || []).filter_map do |arg|
@@ -58,6 +58,12 @@ module Rigor
58
58
  # receiver is `params.require(:symbol)`.
59
59
  STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
60
60
 
61
+ # Max edit distance between a non-column permit key and a real column for the key to count as a
62
+ # typo (and fire `unknown-permit-key`) rather than a legitimate virtual attribute. A tight bound
63
+ # keeps genuine typos (`emial`→`email`, distance 2) while never firing on a virtual attribute,
64
+ # whose name is nothing like any column.
65
+ PERMIT_KEY_TYPO_MAX_DISTANCE = 2
66
+
61
67
  # One Action Pack observation. Carries no path — the caller (the `node_rule` block) positions
62
68
  # it via `Plugin::Base#diagnostic`. `location` is the Prism location the diagnostic should point
63
69
  # at (a call's `message_loc` for the call itself, or a sub-argument's location for
@@ -150,12 +156,11 @@ module Rigor
150
156
  return [] if entry.nil? # unknown model — skip; the model lookup is best-effort.
151
157
 
152
158
  columns = entry[:columns]
153
- spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
154
- literal_permit_keys(call_node).map do |key_node, key_name|
159
+ literal_permit_keys(call_node).filter_map do |key_node, key_name|
155
160
  if columns.include?(key_name)
156
161
  permit_call_violation(call_node, model_class, key_name)
157
162
  else
158
- unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
163
+ unknown_permit_key_violation(key_node, model_class, key_name, columns)
159
164
  end
160
165
  end
161
166
  end
@@ -347,13 +352,23 @@ module Rigor
347
352
  )
348
353
  end
349
354
 
350
- def unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
351
- base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
352
- suggestion = spell_checker.correct(key_name).first
353
- message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
355
+ # A permit key that is not a column is only a violation when it is a near-miss TYPO of a real
356
+ # column. Permitting a non-column is ordinary Rails: a Devise virtual attribute (`password`,
357
+ # `remember_me`, `otp_attempt`), a state-machine `*_event`, an `attr_accessor` setter, or a nested
358
+ # `*_attributes` key are all mass-assignable but absent from the schema. So fire only when a column
359
+ # sits within a tight edit distance of the key (a typo like `emial`→`email`); a key with no close
360
+ # column is presumed a deliberate virtual attribute and passes silently. Firing on every non-column
361
+ # key false-positives across the whole strong-params surface of a Devise/state-machine app.
362
+ def unknown_permit_key_violation(key_node, model_class, key_name, columns)
363
+ nearest = columns.min_by { |col| DidYouMean::Levenshtein.distance(key_name, col) }
364
+ return nil if nearest.nil?
365
+ return nil if DidYouMean::Levenshtein.distance(key_name, nearest) > PERMIT_KEY_TYPO_MAX_DISTANCE
366
+
354
367
  Violation.new(
355
368
  location: key_node.location,
356
- message: message, severity: :error, rule: "unknown-permit-key"
369
+ message: "Action Pack permit `#{key_name}` is not a column on `#{model_class}`. " \
370
+ "Did you mean `:#{nearest}`?",
371
+ severity: :error, rule: "unknown-permit-key"
357
372
  )
358
373
  end
359
374
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "controller_index"
@@ -89,7 +91,7 @@ module Rigor
89
91
 
90
92
  walk_declarations(node.body, namespace: inner_namespace, &)
91
93
  else
92
- node.compact_child_nodes.each do |child|
94
+ node.rigor_each_child do |child|
93
95
  walk_declarations(child, namespace: namespace, &)
94
96
  end
95
97
  end
@@ -142,7 +144,7 @@ module Rigor
142
144
  return accumulator unless node.is_a?(Prism::Node)
143
145
 
144
146
  accumulator << node.name if node.is_a?(Prism::DefNode) && node.receiver.nil?
145
- node.compact_child_nodes.each { |child| collect_def_names(child, accumulator) }
147
+ node.rigor_each_child { |child| collect_def_names(child, accumulator) }
146
148
  accumulator
147
149
  end
148
150
 
@@ -160,7 +162,7 @@ module Rigor
160
162
  end
161
163
  end
162
164
 
163
- node.compact_child_nodes.each { |child| collect_include_targets(child, accumulator) }
165
+ node.rigor_each_child { |child| collect_include_targets(child, accumulator) }
164
166
  accumulator
165
167
  end
166
168
 
@@ -182,6 +182,27 @@ module Rigor
182
182
  Rigor::Type::Combinator.nominal_of(class_name)
183
183
  end
184
184
 
185
+ # Phase 5b (2026-07-10) — keep the strong-parameters fluent chain typed. `params` types to
186
+ # `ActionController::Parameters` (above), but the chained `params.require(:user).permit(:name)` /
187
+ # `params.permit(...)` calls returned `Dynamic` at the first hop (Parameters ships no bundled RBS,
188
+ # so a call on it resolves lenient-to-Dynamic), leaking every downstream site (`.permit`, `.to_h`,
189
+ # `.each`) to unprotected. These three methods return another `Parameters`, so gate on a
190
+ # `Parameters` receiver and re-type the result as the same lenient nominal — the chain stays a
191
+ # concrete receiver end-to-end and `coverage --protection` counts the sites as protected.
192
+ #
193
+ # `require` may return a scalar for a flat key at runtime (`params.require(:id) → String`), but
194
+ # typing it as the RBS-less `Parameters` is FP-safe by the same argument as the readers: every
195
+ # method on a Parameters value stays engine-lenient (no `undefined-method`), and this types the
196
+ # container only, never a caller's argument (ADR-5). The GitLab strong-params survey (108 leaked
197
+ # `.permit` sites) is the demand.
198
+ STRONG_PARAMS_CHAIN_METHODS = %i[require permit permit!].freeze
199
+
200
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, _scope|
201
+ next nil unless call_node.is_a?(Prism::CallNode)
202
+
203
+ Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
204
+ end
205
+
185
206
  private
186
207
 
187
208
  # True when the current `self` is a controller — the enclosing class is one the discoverer
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  require_relative "job_index"
@@ -66,7 +68,7 @@ module Rigor
66
68
  when Prism::ClassNode then visit_class(node, lexical_path, &)
67
69
  when Prism::ModuleNode then visit_module(node, lexical_path, &)
68
70
  else
69
- node.compact_child_nodes.each { |child| walk_for_jobs(child, lexical_path, &) }
71
+ node.rigor_each_child { |child| walk_for_jobs(child, lexical_path, &) }
70
72
  end
71
73
  end
72
74
 
@@ -118,7 +120,7 @@ module Rigor
118
120
  def lookup_perform_def(body)
119
121
  return nil if body.nil?
120
122
 
121
- body.compact_child_nodes.each do |node|
123
+ body.rigor_each_child do |node|
122
124
  next unless node.is_a?(Prism::DefNode) && node.name == :perform
123
125
  next if node.receiver.is_a?(Prism::SelfNode)
124
126
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rigor/source/node_children"
4
+
3
5
  require "prism"
4
6
 
5
7
  module Rigor
@@ -50,7 +52,7 @@ module Rigor
50
52
  return if node.nil?
51
53
 
52
54
  yield node
53
- node.compact_child_nodes.each { |child| walk(child, &) }
55
+ node.rigor_each_child { |child| walk(child, &) }
54
56
  end
55
57
 
56
58
  def visit_call(node)
@@ -90,7 +92,7 @@ module Rigor
90
92
  # than firing a false `unknown-column` against every key.
91
93
  return push_recognised(node, entry, keyword_pairs.map { |p| p[:key] }) if entry.column_names.empty?
92
94
 
93
- unknown = keyword_pairs.reject { |pair| valid_query_key?(entry, pair[:key]) }
95
+ unknown = keyword_pairs.reject { |pair| nested_condition?(pair[:value]) || valid_query_key?(entry, pair[:key]) }
94
96
  if unknown.empty?
95
97
  keyword_pairs.each { |pair| validate_enum_value(node, entry, pair) }
96
98
  push_recognised(node, entry, keyword_pairs.map { |p| p[:key] })
@@ -129,6 +131,15 @@ module Rigor
129
131
  assoc && assoc[:kind] == :singular
130
132
  end
131
133
 
134
+ # A keyword pair with a Hash value is a nested / joined-table condition (`Approval.where(approvals:
135
+ # { user_id: 1 })`, `where(users: { name: 'x' })`), where the key names an association or a joined
136
+ # table, not a column on the receiver. Rails resolves it through the join, so it must not be checked
137
+ # against the receiver's own columns. (An Array value — `where(status: [:a, :b])` — is an IN query on
138
+ # a real column, not nested, so only a Hash is skipped.)
139
+ def nested_condition?(value_node)
140
+ value_node.is_a?(Prism::HashNode) || value_node.is_a?(Prism::KeywordHashNode)
141
+ end
142
+
132
143
  # When the column is an enum-bearing column AND the passed value is a Symbol literal, the value
133
144
  # MUST be one of the declared enum values. Non-Symbol values (variables, expressions) decline so
134
145
  # dynamic call sites stay silent. Sequences (`status: [:active, :archived]`) are intentionally NOT