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
@@ -6,6 +6,7 @@ require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../type"
8
8
  require_relative "../source/literals"
9
+ require_relative "../source/node_children"
9
10
  require_relative "../inference/scope_indexer"
10
11
 
11
12
  module Rigor
@@ -126,7 +127,7 @@ module Rigor
126
127
  end
127
128
  end
128
129
 
129
- node.compact_child_nodes.each { |child| walk_class_decls(child, prefix, accumulator) }
130
+ node.rigor_each_child { |child| walk_class_decls(child, prefix, accumulator) }
130
131
  end
131
132
 
132
133
  def qualified_constant_path(constant_path)
@@ -146,7 +147,7 @@ module Rigor
146
147
  return unless node.is_a?(Prism::Node)
147
148
 
148
149
  record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
149
- node.compact_child_nodes.each { |child| walk_calls(child, scope_index, bindings, observations) }
150
+ node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
150
151
  end
151
152
 
152
153
  def record_call(call_node, scope_index, bindings, observations)
@@ -244,7 +245,7 @@ module Rigor
244
245
  recognise_describe(node, bindings)
245
246
  recognise_subject_or_let(node, bindings, scope_index)
246
247
 
247
- node.compact_child_nodes.each { |child| walk_rspec_bindings(child, bindings, scope_index) }
248
+ node.rigor_each_child { |child| walk_rspec_bindings(child, bindings, scope_index) }
248
249
  end
249
250
 
250
251
  def recognise_describe(node, bindings)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ module SigGen
7
+ # Parses what sig-gen is about to emit, before it is emitted.
8
+ #
9
+ # Two rendering bugs have shipped RBS that `rbs` itself rejects — a non-identifier record key
10
+ # (`{ :"data-contrast" => T }`) and a `&block` constructor parameter rendered before the parens
11
+ # (`(**untyped, ?{ (?) -> void })`). Both were found downstream, as a poisoned `sig/` tree: the file is
12
+ # quarantined, its types vanish, and (for a project adopting `reject-unparseable-signatures`) the build
13
+ # fails. A generator that can poison its own consumer's build is worth guarding at the source.
14
+ #
15
+ # So this is not a check for ONE bug — it is the guard that turns the whole rendering-bug *class* from a
16
+ # silent bad artifact into a caught error naming the method that could not be rendered. It is cheap: the
17
+ # text is already in memory, and `RBS::Parser` is the same C parser the consumer will use, so it is exactly
18
+ # the oracle that matters (not an approximation of it).
19
+ module RbsValidity
20
+ # A rendered method line (`def foo: () -> String`) is a fragment, not a compilation unit — it has to be
21
+ # wrapped to be parsed at all. The wrapper name is irrelevant to the parse and never reaches disk.
22
+ PROBE_CLASS = "RigorSigGenProbe"
23
+
24
+ module_function
25
+
26
+ # @param line [String] a rendered RBS method line, e.g. `def self.parse: (String) -> Integer`.
27
+ # @return [String, nil] the parse error's first line, or nil when the line is valid.
28
+ def method_line_error(line)
29
+ return nil if line.nil?
30
+
31
+ source_error("class #{PROBE_CLASS}\n #{line}\nend\n")
32
+ end
33
+
34
+ # @param source [String] a complete `.rbs` file's text.
35
+ # @return [String, nil] the parse error's first line, or nil when the file is valid.
36
+ def source_error(source)
37
+ ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor sig-gen)", content: source))
38
+ nil
39
+ rescue ::RBS::ParsingError => e
40
+ e.message.to_s.lines.first.to_s.strip
41
+ end
42
+ end
43
+ end
44
+ end
@@ -126,6 +126,7 @@ module Rigor
126
126
  when :created then render_write_created(result)
127
127
  when :updated then render_write_updated(result)
128
128
  when :skipped_outside_sig_root then render_write_skipped(result)
129
+ when :skipped_invalid_rbs then render_write_invalid(result)
129
130
  end
130
131
  end
131
132
  end
@@ -143,6 +144,15 @@ module Rigor
143
144
  @out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
144
145
  end
145
146
 
147
+ # The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
148
+ # consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
149
+ # including the user's own hand-written ones.
150
+ def render_write_invalid(result)
151
+ @out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
152
+ @out.puts(" #{result.error}")
153
+ @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
154
+ end
155
+
146
156
  def render_write_json(results)
147
157
  @out.puts(JSON.pretty_generate({ results: results.map(&:to_h) }))
148
158
  end
@@ -10,19 +10,24 @@ module Rigor
10
10
  # - `source_path` — original `.rb` file.
11
11
  # - `target_path` — `.rbs` file the writer was responsible for (`nil` when the source path falls outside
12
12
  # the project signature tree, in which case `action` is `:skipped_outside_sig_root`).
13
- # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root`.
13
+ # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root` /
14
+ # `:skipped_invalid_rbs`.
14
15
  # - `applied` — the {MethodCandidate}s that actually landed on disk.
15
16
  # - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
16
17
  # entry pairs the candidate with a skip reason keyword (`:user_authored`).
18
+ # - `error` — the parse error, when `action` is `:skipped_invalid_rbs`: the file the writer assembled does
19
+ # not parse, so it was NOT written (writing it would poison the project's sig tree — the consumer
20
+ # quarantines an unparseable `.rbs`, taking every other type in that file down with it).
17
21
  class WriteResult
18
- attr_reader :source_path, :target_path, :action, :applied, :skipped
22
+ attr_reader :source_path, :target_path, :action, :applied, :skipped, :error
19
23
 
20
- def initialize(source_path:, target_path:, action:, applied: [], skipped: [])
24
+ def initialize(source_path:, target_path:, action:, applied: [], skipped: [], error: nil)
21
25
  @source_path = source_path
22
26
  @target_path = target_path
23
27
  @action = action
24
28
  @applied = applied.freeze
25
29
  @skipped = skipped.freeze
30
+ @error = error
26
31
  freeze
27
32
  end
28
33
 
@@ -33,7 +38,7 @@ module Rigor
33
38
  action: action.to_s,
34
39
  applied: applied.map(&:to_h),
35
40
  skipped: skipped.map { |c, reason| c.to_h.merge(write_skip_reason: reason.to_s) }
36
- }
41
+ }.tap { |h| h[:error] = error if error }
37
42
  end
38
43
  end
39
44
  end
@@ -108,8 +108,15 @@ module Rigor
108
108
  end
109
109
 
110
110
  def create_new(source_path, target, candidates)
111
+ content = render_new_file(candidates)
112
+ error = RbsValidity.source_error(content)
113
+ if error
114
+ return WriteResult.new(source_path: source_path, target_path: target,
115
+ action: :skipped_invalid_rbs, error: error)
116
+ end
117
+
111
118
  FileUtils.mkdir_p(target.dirname)
112
- target.write(render_new_file(candidates))
119
+ target.write(content)
113
120
  WriteResult.new(source_path: source_path, target_path: target,
114
121
  action: :created, applied: candidates)
115
122
  end
@@ -272,35 +279,65 @@ module Rigor
272
279
  return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
273
280
 
274
281
  state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
275
- supers = merged_superclasses(candidates)
276
- candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods, supers) }
277
- merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
282
+ merge_candidates(state, candidates)
278
283
 
279
284
  action = state.applied.empty? ? :noop : :updated
280
- target.write(state.source) if action == :updated
285
+ unless action == :updated
286
+ return WriteResult.new(source_path: source_path, target_path: target, action: action,
287
+ applied: state.applied, skipped: state.skipped)
288
+ end
289
+
290
+ # The merge splices text into an existing file by byte offset, so a bug here can produce a file neither
291
+ # the generator nor the target was responsible for. Parse the assembled result and refuse to write it if
292
+ # it is broken: a `.rbs` the consumer cannot parse is quarantined whole, so a bad splice would delete
293
+ # every type in the file — including the user's own, which were fine before we touched them.
294
+ error = RbsValidity.source_error(state.source)
295
+ if error
296
+ return WriteResult.new(source_path: source_path, target_path: target,
297
+ action: :skipped_invalid_rbs, error: error)
298
+ end
299
+
300
+ target.write(state.source)
281
301
  WriteResult.new(source_path: source_path, target_path: target,
282
302
  action: action, applied: state.applied, skipped: state.skipped)
283
303
  end
284
304
 
305
+ # Applies every class group, then every requested shell, then normalises the layout the three steps
306
+ # leave behind. Each step mutates `state` in place and re-parses, so the next one sees current offsets.
307
+ def merge_candidates(state, candidates)
308
+ supers = merged_superclasses(candidates)
309
+ kinds = merged_namespace_kinds(candidates)
310
+ shells = collect_class_shells(candidates)
311
+ groups = candidates.group_by(&:class_name)
312
+ # Ancestors first, so `Foo` is created (or found) before `Foo::Bar` looks for a parent to nest under.
313
+ # A plain sort suffices: a name always sorts before every name it is a strict prefix of.
314
+ groups.keys.sort.each { |name| merge_class(state, name, groups.fetch(name), kinds, supers) }
315
+ merge_class_shells(state, shells, kinds, supers)
316
+ collapse_nested_declarations(state, groups.keys + shells.to_a)
317
+ end
318
+
285
319
  # ADR-14 gap-#3 (e): for every requested class shell that isn't already declared in the target file,
286
320
  # insert an empty `class Const\nend` block inside the nearest existing ancestor. Shells already covered by
287
321
  # an existing declaration are silently a no-op. The `applied` accumulator does NOT grow — shells are
288
322
  # structural declarations, not methods, so the action-count surface (`updated +N`) keeps reflecting method
289
323
  # changes only.
290
- def merge_class_shells(state, shells, kinds)
324
+ def merge_class_shells(state, shells, kinds, supers)
291
325
  shells.each do |qualified|
292
326
  next if find_class_decl(state.decls, qualified)
293
327
 
294
- insert_class_shell(state, qualified, kinds)
328
+ insert_namespace_chain(state, qualified, kinds, supers, [])
295
329
  end
296
330
  end
297
331
 
298
- def insert_class_shell(state, qualified, kinds)
332
+ # Splices the missing part of `qualified`'s namespace chain into the nearest declaration the file
333
+ # already has for one of its ancestors, or at top level when it has none. `methods` land on the leaf
334
+ # node, so this is the single insertion path for both an empty class shell and a brand-new class.
335
+ def insert_namespace_chain(state, qualified, kinds, supers, methods)
299
336
  segments = qualified.split("::")
300
337
  anchor_segs, missing = split_at_existing_ancestor(state.decls, segments)
301
338
  anchor_decl = anchor_segs.empty? ? nil : find_class_decl(state.decls, anchor_segs.join("::"))
302
- depth = anchor_decl ? anchor_decl_indent_depth(anchor_decl) : 0
303
- snippet = build_shell_snippet(missing, anchor_segs, kinds, depth)
339
+ depth = anchor_decl ? member_indent_depth(anchor_decl) : 0
340
+ snippet = render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
304
341
  state.source = if anchor_decl
305
342
  insert_before_end(state.source, anchor_decl, snippet)
306
343
  else
@@ -309,6 +346,113 @@ module Rigor
309
346
  state.decls = parse_signature(state.source) || state.decls
310
347
  end
311
348
 
349
+ # ADR-14 gap-#3 follow-up (c), update half: the create path folds a strict-prefix pair into one nested
350
+ # tree, but a file written before that fix — or by hand — can still carry the flat sibling layout
351
+ # (`class Foo` next to a top-level `class Foo::Bar`). After merging, relocate each declaration this run
352
+ # touched underneath its parent's declaration when the same file holds both, so an update converges on
353
+ # the same canonical layout a fresh generation would produce.
354
+ #
355
+ # Scope is deliberately the touched names only: an unrelated flat pair elsewhere in the file is none of
356
+ # sig-gen's business, and rewriting it would be a layout change the user never asked for. Shallowest
357
+ # first, so `Foo::Bar` has already moved under `Foo` by the time `Foo::Bar::Baz` looks for its parent.
358
+ def collapse_nested_declarations(state, names)
359
+ names.uniq.sort.each { |name| relocate_under_parent(state, name) }
360
+ end
361
+
362
+ def relocate_under_parent(state, qualified)
363
+ segments = qualified.split("::")
364
+ return if segments.size < 2
365
+
366
+ parent_name = segments[0...-1].join("::")
367
+ parent = find_class_decl(state.decls, parent_name)
368
+ decl = parent && find_class_decl(state.decls, qualified)
369
+ return if decl.nil? || overlapping?(parent, decl)
370
+
371
+ region = decl_region(state.source, decl)
372
+ block = region && relocated_block(state.source, decl, parent, segments.last, region)
373
+ return if block.nil?
374
+
375
+ apply_relocation(state, region, parent_name, block)
376
+ end
377
+
378
+ # Cuts the declaration's region out, re-parses so the parent's byte range reflects the removal, and
379
+ # splices the re-indented block back in as the parent's last member. Any step that cannot be carried
380
+ # out cleanly (an unparseable intermediate, a parent that vanished) abandons the move and leaves the
381
+ # merged source exactly as it was — a flat layout is cosmetic, a mangled `.rbs` is not.
382
+ def apply_relocation(state, region, parent_name, block)
383
+ source = splice_out(state.source, region)
384
+ decls = parse_signature(source)
385
+ anchor = decls && find_class_decl(decls, parent_name)
386
+ return if anchor.nil?
387
+
388
+ state.source = insert_before_end(source, anchor, block)
389
+ state.decls = parse_signature(state.source) || state.decls
390
+ end
391
+
392
+ # True when the two declarations' source ranges are not disjoint — either one already nests the other,
393
+ # or the file's shape is one this pass does not understand. Both are reasons not to move anything.
394
+ def overlapping?(one, other)
395
+ one.location.start_pos < other.location.end_pos && other.location.start_pos < one.location.end_pos
396
+ end
397
+
398
+ # The declaration's full source region, extended to cover its leading comment and annotations and to
399
+ # end just past the newline that closes it. Returns `nil` when either edge shares a line with something
400
+ # else, because cutting there would move — or strand — text the declaration does not own.
401
+ def decl_region(source, decl)
402
+ start_pos = ([decl.location.start_pos] + leading_positions(decl)).min
403
+ line_start = line_start_index(source, start_pos)
404
+ return nil unless blank_range?(source, line_start, start_pos)
405
+
406
+ finish = decl.location.end_pos
407
+ line_end = source.index("\n", finish) || source.size
408
+ return nil unless blank_range?(source, finish, line_end)
409
+
410
+ line_start...(line_end < source.size ? line_end + 1 : line_end)
411
+ end
412
+
413
+ def leading_positions(decl)
414
+ positions = decl.annotations.filter_map { |a| a.location&.start_pos }
415
+ comment_location = decl.comment&.location
416
+ positions << comment_location.start_pos if comment_location
417
+ positions
418
+ end
419
+
420
+ # The moved text, with its compact `Foo::Bar` head shortened to `Bar` and every line pushed in to the
421
+ # parent's member depth. The name's own sub-location drives the rewrite, so a superclass, type
422
+ # parameters, and the whole body survive byte-for-byte.
423
+ def relocated_block(source, decl, parent, short_name, region)
424
+ name_location = decl.location[:name]
425
+ return nil if name_location.nil?
426
+
427
+ text = source[region].to_s
428
+ head = name_location.start_pos - region.begin
429
+ tail = name_location.end_pos - region.begin
430
+ renamed = text[0...head].to_s + short_name + text[tail..].to_s
431
+ reindent(renamed, member_indent_depth(parent) - decl_indent_depth(decl))
432
+ end
433
+
434
+ def reindent(text, delta)
435
+ return text unless delta.positive?
436
+
437
+ prefix = INDENT * delta
438
+ text.lines.map { |line| line.match?(/\A\s*\z/) ? line : prefix + line }.join
439
+ end
440
+
441
+ # Removes `region`, collapsing the newline run left behind at the seam so the cut never shows up as a
442
+ # widening gap (or a trailing blank line at EOF) in the file it edited. Spacing that was already fine
443
+ # is left exactly as the user wrote it.
444
+ def splice_out(source, region)
445
+ before = source[0...region.begin].to_s
446
+ after = source[region.end..].to_s
447
+ return after.sub(/\A\n+/, "") if before.match?(/\A\s*\z/)
448
+ return before.sub(/\n{2,}\z/, "\n") if after.match?(/\A\s*\z/)
449
+
450
+ seam = before[/\n+\z/].to_s.size + after[/\A\n+/].to_s.size
451
+ return before + after if seam <= 2
452
+
453
+ "#{before.sub(/\n+\z/, "\n\n")}#{after.sub(/\A\n+/, '')}"
454
+ end
455
+
312
456
  def split_at_existing_ancestor(decls, segments)
313
457
  (segments.size - 1).downto(0).each do |i|
314
458
  ancestor = segments[0...i].join("::")
@@ -317,37 +461,61 @@ module Rigor
317
461
  [[], segments]
318
462
  end
319
463
 
320
- # Pulls the indent depth (in `INDENT` units) one level deeper than the anchor decl's own column.
321
- # Pre-existing members might be missing (an empty `class Foo; end`) so the keyword column is the robust
322
- # signal.
323
- def anchor_decl_indent_depth(decl)
324
- decl_column = decl.location[:keyword].start_column
325
- (decl_column / INDENT.size) + 1
464
+ # The indent depth (in `INDENT` units) a member of `decl` sits at: one level deeper than the
465
+ # declaration's own keyword column. Pre-existing members might be missing (an empty `class Foo; end`)
466
+ # so the keyword column is the robust signal.
467
+ def member_indent_depth(decl)
468
+ decl_indent_depth(decl) + 1
469
+ end
470
+
471
+ def decl_indent_depth(decl)
472
+ decl.location[:keyword].start_column / INDENT.size
326
473
  end
327
474
 
328
- def build_shell_snippet(missing, anchor_segs, kinds, depth)
475
+ # Renders the `missing` segment chain as one nested block, carrying `methods` on its leaf, by handing a
476
+ # synthesised tree node to the create path's renderer. Both paths therefore agree on the keyword, the
477
+ # superclass suffix, and the indentation of every level. A leaf with no methods is a class shell, which
478
+ # is exactly the create path's `shell:` node (gap-#3 (e)).
479
+ def render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
329
480
  return "" if missing.empty?
330
481
 
331
- head, *rest = missing
332
- qualified = (anchor_segs + [head]).join("::")
333
- indent = INDENT * depth
334
- if rest.empty?
335
- keyword = kinds[qualified] || :class
336
- "#{indent}#{keyword} #{head}\n#{indent}end\n"
337
- else
338
- inner = build_shell_snippet(rest, anchor_segs + [head], kinds, depth + 1)
339
- keyword = kinds[qualified] || :module
340
- "#{indent}#{keyword} #{head}\n#{inner}#{indent}end\n"
482
+ node = missing.reverse.each_with_index.inject(nil) do |child, (segment, index)|
483
+ { name: segment, children: child ? { child[:name] => child } : {},
484
+ methods: index.zero? ? methods : [], shell: index.zero? && methods.empty? }
341
485
  end
486
+ render_tree_node(node, kinds, supers, depth, anchor_segs)
342
487
  end
343
488
 
489
+ # Splices `snippet` in just before the declaration's closing `end`. When that `end` starts its own line
490
+ # the insertion point moves to the START of that line: the snippet carries its own indentation, and
491
+ # anchoring at the keyword would otherwise donate the `end`'s indent to the snippet's first line and
492
+ # strand the `end` in column zero.
344
493
  def insert_before_end(source, decl, snippet)
494
+ return source if snippet.empty?
495
+
345
496
  end_pos = decl.location[:end].start_pos
346
- source[0...end_pos] + snippet + source[end_pos..]
497
+ line_start = line_start_index(source, end_pos)
498
+ return source[0...line_start] + snippet + source[line_start..] if blank_range?(source, line_start, end_pos)
499
+
500
+ "#{source[0...end_pos]}\n#{snippet}#{source[end_pos..]}"
347
501
  end
348
502
 
503
+ # Appends a top-level block, separated from whatever precedes it by exactly one blank line.
349
504
  def append_top_level(source, snippet)
350
- ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
505
+ return snippet if source.empty?
506
+
507
+ base = ends_with_newline?(source) ? source : "#{source}\n"
508
+ base.end_with?("\n\n") ? base + snippet : "#{base}\n#{snippet}"
509
+ end
510
+
511
+ def line_start_index(source, pos)
512
+ return 0 unless pos.positive?
513
+
514
+ (source.rindex("\n", pos - 1) || -1) + 1
515
+ end
516
+
517
+ def blank_range?(source, from, to)
518
+ source[from...to].to_s.match?(/\A[ \t]*\z/)
351
519
  end
352
520
 
353
521
  def parse_signature(source)
@@ -357,14 +525,15 @@ module Rigor
357
525
  nil
358
526
  end
359
527
 
360
- def merge_class(state, class_name, methods, supers = {})
528
+ def merge_class(state, class_name, methods, kinds, supers)
361
529
  decl = find_class_decl(state.decls, class_name)
362
- state.source = if decl.nil?
363
- append_new_class(state.source, class_name, methods, state.applied, supers[class_name])
364
- else
365
- merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
366
- end
367
- state.decls = parse_signature(state.source) || state.decls
530
+ if decl.nil?
531
+ state.applied.concat(methods)
532
+ insert_namespace_chain(state, class_name, kinds, supers, methods)
533
+ else
534
+ state.source = merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
535
+ state.decls = parse_signature(state.source) || state.decls
536
+ end
368
537
  end
369
538
 
370
539
  # Walks the parsed decl tree recursively, tracking the enclosing module/class prefix, and returns the
@@ -389,16 +558,6 @@ module Rigor
389
558
  nil
390
559
  end
391
560
 
392
- # Appends an entirely new `class Foo … end` block at the end of the file (with a leading blank line as
393
- # separator).
394
- def append_new_class(source, class_name, methods, applied, superclass = nil)
395
- body = methods.map { |c| "#{INDENT}#{c.rbs}" }.join("\n")
396
- header = superclass ? "class #{class_name} < #{superclass}" : "class #{class_name}"
397
- snippet = "\n#{header}\n#{body}\nend\n"
398
- applied.concat(methods)
399
- ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
400
- end
401
-
402
561
  def ends_with_newline?(source)
403
562
  source.end_with?("\n")
404
563
  end
@@ -455,9 +614,8 @@ module Rigor
455
614
  def insert_into_class(source, decl, new_methods)
456
615
  return source if new_methods.empty?
457
616
 
458
- end_pos = decl.location[:end].start_pos
459
- addition = new_methods.map { |c| "#{INDENT}#{c.rbs}\n" }.join
460
- source[0...end_pos] + addition + source[end_pos..]
617
+ indent = INDENT * member_indent_depth(decl)
618
+ insert_before_end(source, decl, new_methods.map { |c| "#{indent}#{c.rbs}\n" }.join)
461
619
  end
462
620
 
463
621
  # Walks the class's existing method declarations; for each replaceable candidate that matches a member
@@ -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"