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
@@ -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.9"
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|
@@ -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
 
@@ -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)
@@ -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
 
@@ -140,7 +142,7 @@ module Rigor
140
142
  when Prism::ModuleNode
141
143
  visit_module(node, lexical_path, &)
142
144
  else
143
- node.compact_child_nodes.each { |child| walk_for_classes(child, lexical_path, &) }
145
+ node.rigor_each_child { |child| walk_for_classes(child, lexical_path, &) }
144
146
  end
145
147
  end
146
148
 
@@ -256,7 +258,7 @@ module Rigor
256
258
  def lookup_table_name_override(body)
257
259
  return nil if body.nil?
258
260
 
259
- body.compact_child_nodes.each do |node|
261
+ body.rigor_each_child do |node|
260
262
  next unless node.is_a?(Prism::CallNode) && node.name == :table_name=
261
263
  next unless node.receiver.is_a?(Prism::SelfNode)
262
264
 
@@ -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
@@ -66,7 +68,7 @@ module Rigor
66
68
 
67
69
  yield node if node.is_a?(Prism::CallNode) && node.name == :create_table && node.receiver.nil?
68
70
 
69
- node.compact_child_nodes.each { |child| collect_create_table_calls(child, &) }
71
+ node.rigor_each_child { |child| collect_create_table_calls(child, &) }
70
72
  end
71
73
 
72
74
  def parse_create_table(call_node)
@@ -118,7 +120,7 @@ module Rigor
118
120
  return
119
121
  end
120
122
 
121
- node.compact_child_nodes.each { |child| collect_column_calls(child, &) }
123
+ node.rigor_each_child { |child| collect_column_calls(child, &) }
122
124
  end
123
125
 
124
126
  def parse_column(call_node)
@@ -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
@@ -32,7 +34,7 @@ module Rigor
32
34
  return if node.nil?
33
35
 
34
36
  yield node
35
- node.compact_child_nodes.each { |child| walk(child, &) }
37
+ node.rigor_each_child { |child| walk(child, &) }
36
38
  end
37
39
 
38
40
  def visit_call(node)
@@ -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
 
@@ -77,7 +79,7 @@ module Rigor
77
79
  when Prism::ModuleNode
78
80
  visit_module(node, lexical_path, &)
79
81
  else
80
- node.compact_child_nodes.each { |child| walk(child, lexical_path, &) }
82
+ node.rigor_each_child { |child| walk(child, lexical_path, &) }
81
83
  end
82
84
  end
83
85
 
@@ -105,7 +107,7 @@ module Rigor
105
107
  return [] if body.nil?
106
108
 
107
109
  rows = []
108
- body.compact_child_nodes.each do |node|
110
+ body.rigor_each_child do |node|
109
111
  next unless node.is_a?(Prism::CallNode)
110
112
 
111
113
  kind = ATTACHMENT_METHODS[node.name]