rigortype 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -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.1"
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]
@@ -42,6 +42,25 @@ module Rigor
42
42
  # informational only (deferred: cross-plugin handoff to a JS-side analyzer).
43
43
  # - **`broadcast_to` arity isn't checked.** The method takes any record + any data hash; there's no
44
44
  # useful arity envelope.
45
+ #
46
+ # ## `#receive(data)` protocol contract (ADR-28)
47
+ #
48
+ # ActionCable's `Connection::Subscriptions#perform_action` dispatches an incoming message to the
49
+ # action named by the payload's `"action"` key, defaulting to `:receive` when that key is absent —
50
+ # i.e. `#receive(data)` is the framework-documented catch-all handler for messages with no explicit
51
+ # action. Either way the single positional argument is always the `ActiveSupport::JSON.decode` of the
52
+ # client-sent payload, which is a JSON object and therefore a `Hash` by convention (the bundled JS
53
+ # client only ever calls `perform(action, data = {})` with an object). This plugin declares a
54
+ # path-scoped protocol contract (`method_name: :receive`, `param_types: [{index: 0, type_name:
55
+ # "Hash"}]`) so `data` types as `Hash` instead of `Dynamic[Top]` inside a channel's `#receive` body.
56
+ #
57
+ # Deliberately narrower than Hanami's `#handle`: custom action methods (`def speak(data)`) also
58
+ # receive the decoded Hash, but their names are project-chosen, not a single fixed Symbol a
59
+ # `ProtocolContract` can name — only `:receive` is a framework-reserved, uniformly-shaped method name.
60
+ # Per ADR-28, the contract is path-scoped, not class-scoped: any `#receive(data)` defined anywhere
61
+ # under the (possibly multi-root) `channel_search_paths` is typed, even on a stray non-channel class
62
+ # that happens to live in that directory — the same accepted-risk shape as `rigor-hanami`'s
63
+ # `#handle`.
45
64
  class Actioncable < Rigor::Plugin::Base
46
65
  manifest(
47
66
  id: "actioncable",
@@ -53,7 +72,15 @@ module Rigor
53
72
  kind: :array,
54
73
  default: ["ApplicationCable::Channel", "ActionCable::Channel::Base"]
55
74
  }
56
- }
75
+ },
76
+ protocol_contracts: [
77
+ Rigor::Plugin::ProtocolContract.new(
78
+ path_glob: "app/channels/**/*.rb",
79
+ method_name: :receive,
80
+ param_types: [{ index: 0, type_name: "Hash" }]
81
+ # return_type_name: nil — receive's return value is discarded by the framework dispatcher.
82
+ )
83
+ ]
57
84
  )
58
85
 
59
86
  # `watch:` covers every `.rb` file under the channel search paths so the cache invalidates when
@@ -70,6 +97,20 @@ module Rigor
70
97
  def init(_services)
71
98
  @channel_search_paths = Array(config.fetch("channel_search_paths")).map(&:to_s)
72
99
  @channel_base_classes = Array(config.fetch("channel_base_classes")).map(&:to_s)
100
+
101
+ # ADR-28 WD5 — `channel_search_paths` is user-configurable and may name multiple roots; retarget
102
+ # the manifest's default `app/channels/**/*.rb` glob to the actual configured root(s) so the
103
+ # contract neither goes inert on a project that renames/adds channel directories nor (more
104
+ # importantly, per the project's false-positive discipline) risks matching an unrelated `#receive`
105
+ # outside them. `File::FNM_EXTGLOB` (enabled by `Registry#contracts_for_path`) makes a `{a,b}`
106
+ # brace group a single valid glob for multiple roots.
107
+ @protocol_contracts = manifest.protocol_contracts.map { |c| c.with_path_glob(channel_search_glob) }
108
+ end
109
+
110
+ # ADR-28 — override so the per-project `channel_search_paths` config reaches both the engine's
111
+ # parameter-provision tier and this plugin's own path-scoped reasoning.
112
+ def protocol_contracts
113
+ @protocol_contracts || manifest.protocol_contracts
73
114
  end
74
115
 
75
116
  # File-level only: the load-error emission. Per-call broadcast validation runs over the
@@ -91,6 +132,15 @@ module Rigor
91
132
 
92
133
  private
93
134
 
135
+ # Builds the `#receive` contract's `path_glob` from the configured `channel_search_paths`. A single
136
+ # root becomes a plain `**/*.rb` glob; multiple roots become an `FNM_EXTGLOB` brace group so every
137
+ # configured root — and only a configured root — is covered.
138
+ def channel_search_glob
139
+ roots = @channel_search_paths.map { |p| p.chomp("/") }
140
+ base = roots.length == 1 ? roots.first : "{#{roots.join(',')}}"
141
+ "#{base}/**/*.rb"
142
+ end
143
+
94
144
  def load_error_diagnostic(path)
95
145
  error = producer_error(:channel_index)
96
146
  Rigor::Analysis::Diagnostic.new(
@@ -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]
@@ -63,12 +63,28 @@ module Rigor
63
63
  produces: [:dry_type_aliases]
64
64
  )
65
65
 
66
- # Walks every project file once during `prepare(services)` to build the alias table, then publishes
67
- # via the ADR-9 fact store. The walk is bounded by the configured `paths:` surface; each file's
68
- # parse error degrades to "no contribution" without polluting the user-visible diagnostic stream.
66
+ # Cached alias-table producer (ADR-60 WD3 record-and-validate). The scan Prism-parses every `.rb` file
67
+ # under the project's `paths:` tree to find `include Dry.Types()` declarations an expensive pass that
68
+ # ran on EVERY invocation (cold and warm) because `#prepare` called {AliasScanner.scan} directly, with
69
+ # no cache. It now rides `cache_for`: a warm run re-globs + re-digests the watched tree (cheap SHA over
70
+ # file bytes, no AST build) and reuses the cached table instead of re-parsing. This is the dominant win
71
+ # even for a project that ships no dry-types module — the parse still has to run to prove that, and now
72
+ # the empty result is cached too.
73
+ #
74
+ # `watch:` covers exactly the files the scan reads: one glob per project `paths:` entry, co-extensive
75
+ # with {#scannable_paths}. A {Cache::Descriptor::GlobEntry} digests every file matching its glob, so a
76
+ # content edit, a file addition, and a file removal anywhere under those paths all move the digest and
77
+ # invalidate the entry. The scan reads no file individually through the `IoBoundary`, so the evaluated
78
+ # `watch:` globs are the entire dependency descriptor — and they fully cover the scan's input.
79
+ producer :dry_type_aliases, watch: -> { alias_watch_globs } do |_params|
80
+ AliasScanner.scan(paths: scannable_paths)
81
+ end
82
+
83
+ # Builds the (cached) alias table and publishes it via the ADR-9 fact store. `producer_value` runs the
84
+ # producer through `cache_for` and memoises the result — including a rescued nil — for the run.
69
85
  def prepare(services)
70
- aliases = AliasScanner.scan(paths: scannable_paths(services))
71
- return if aliases.empty?
86
+ aliases = producer_value(:dry_type_aliases)
87
+ return if aliases.nil? || aliases.empty?
72
88
 
73
89
  services.fact_store.publish(
74
90
  plugin_id: manifest.id,
@@ -86,7 +102,7 @@ module Rigor
86
102
  # Resolves the project's `paths:` to a flat list of `.rb` files the scanner walks. Mirrors
87
103
  # `Analysis::Runner`'s `expand_paths` floor; we don't need the runner's full exclude/sort surface
88
104
  # because the alias table is a union — any duplicate scan is a no-op.
89
- def scannable_paths(services)
105
+ def scannable_paths
90
106
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
91
107
  if File.directory?(entry)
92
108
  Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
@@ -97,6 +113,22 @@ module Rigor
97
113
  end
98
114
  end.uniq.freeze
99
115
  end
116
+
117
+ # The `watch:` glob tuples covering the scan's entire input — one `[root, pattern]` per project `paths:`
118
+ # entry. A directory entry watches its whole `**/*.rb` subtree; a single-file `.rb` entry watches
119
+ # exactly that file (`[dir, basename]`). Roots are expanded to absolute paths by
120
+ # `Plugin::Base#watch_glob_entries`, so a relative CLI path (`app/models`) re-validates against the same
121
+ # tree from the project root on the next run. Co-extensive with {#scannable_paths}: every `.rb` the scan
122
+ # would read is watched, so any edit / addition / removal under those paths invalidates the cache.
123
+ def alias_watch_globs
124
+ services.configuration.paths.filter_map do |entry|
125
+ if File.directory?(entry)
126
+ [entry, "**/*.rb"]
127
+ elsif File.file?(entry) && entry.end_with?(".rb")
128
+ [File.dirname(entry), File.basename(entry)]
129
+ end
130
+ end
131
+ end
100
132
  end
101
133
 
102
134
  Rigor::Plugin.register(DryTypes)
@@ -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
 
@@ -86,7 +88,7 @@ module Rigor
86
88
  visit_factory(node, &)
87
89
  return
88
90
  end
89
- node.compact_child_nodes.each { |child| walk_for_factories(child, &) }
91
+ node.rigor_each_child { |child| walk_for_factories(child, &) }
90
92
  end
91
93
 
92
94
  def factory_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
@@ -65,7 +67,7 @@ module Rigor
65
67
  case node
66
68
  when Prism::DefNode then defs << node
67
69
  when Prism::ClassNode, Prism::ModuleNode then nil # nested scopes own their own defs
68
- else node.compact_child_nodes.each { |child| collect_direct_defs(child, defs) }
70
+ else node.rigor_each_child { |child| collect_direct_defs(child, defs) }
69
71
  end
70
72
  end
71
73
 
@@ -122,6 +122,16 @@ module Rigor
122
122
  carried_type(receiver_type, call_node.name)
123
123
  end
124
124
 
125
+ # ADR-88 WD1 — the `dynamic_return` contribution is a PURE STRUCTURAL projection of the receiver's own
126
+ # carrier type args (`Result[T, E]#unwrap → T`), computed per call from the receiver type the engine
127
+ # already resolved; it reads no cross-file catalog. (The `Enum` nested-class emission rides the ADR-16
128
+ # macro substrate / synthetic-method scanner, a separate pre-pass, not this `dynamic_return`.) A stable
129
+ # sentinel declares "no cross-file fact surface", keeping the plugin incremental-capable; the
130
+ # `--verify-incremental` gate backstops the claim.
131
+ def incremental_state_fingerprint
132
+ "structural-carriers"
133
+ end
134
+
125
135
  private
126
136
 
127
137
  # @return [Rigor::Type, nil] the receiver's inferred type, or nil when the engine raises on a synthetic