rigortype 0.4.1 → 0.4.2

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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +6 -0
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/03-configuration.md +1 -1
  27. data/docs/manual/04-diagnostics.md +2 -1
  28. data/docs/manual/06-baseline.md +2 -0
  29. data/docs/manual/07-plugins.md +1 -1
  30. data/docs/manual/18-removing-dead-code.md +32 -2
  31. data/docs/manual/plugins/README.md +6 -1
  32. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  33. data/docs/manual/plugins/rigor-alba.md +60 -0
  34. data/docs/manual/plugins/rigor-minitest.md +22 -4
  35. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  36. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  37. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  38. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  39. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  40. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  41. data/lib/rigor/analysis/check_rules.rb +289 -57
  42. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  43. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  44. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  45. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  46. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  47. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  48. data/lib/rigor/analysis/incremental.rb +29 -9
  49. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  50. data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
  51. data/lib/rigor/analysis/incremental_session.rb +14 -1
  52. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  53. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  54. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  55. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  56. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
  57. data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
  58. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  59. data/lib/rigor/analysis/runner.rb +34 -3
  60. data/lib/rigor/analysis/worker_session.rb +7 -4
  61. data/lib/rigor/cache/incremental_snapshot.rb +22 -0
  62. data/lib/rigor/cli/annotate_command.rb +5 -0
  63. data/lib/rigor/cli/baseline_command.rb +7 -18
  64. data/lib/rigor/cli/check_command.rb +2 -1
  65. data/lib/rigor/cli/trace_command.rb +5 -0
  66. data/lib/rigor/cli/type_of_command.rb +5 -3
  67. data/lib/rigor/cli/unused_command.rb +13 -7
  68. data/lib/rigor/cli.rb +10 -2
  69. data/lib/rigor/configuration/severity_profile.rb +6 -0
  70. data/lib/rigor/configuration.rb +46 -2
  71. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  72. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  73. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  74. data/lib/rigor/environment/rbs_loader.rb +283 -14
  75. data/lib/rigor/environment/required_features.rb +206 -0
  76. data/lib/rigor/environment.rb +21 -3
  77. data/lib/rigor/inference/acceptance.rb +28 -3
  78. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  79. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  81. data/lib/rigor/inference/expression_typer.rb +215 -16
  82. data/lib/rigor/inference/global_write_census.rb +7 -4
  83. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  84. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  85. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  86. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  87. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  88. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  89. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  90. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  91. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  92. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  93. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  94. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  95. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  96. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  97. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  98. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  99. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  100. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
  101. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  103. data/lib/rigor/inference/module_function_state.rb +2 -1
  104. data/lib/rigor/inference/narrowing.rb +20 -6
  105. data/lib/rigor/inference/object_mixins.rb +180 -0
  106. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  107. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  108. data/lib/rigor/inference/refine_census.rb +239 -0
  109. data/lib/rigor/inference/refine_self.rb +317 -0
  110. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  111. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  112. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  113. data/lib/rigor/inference/scope_indexer.rb +687 -60
  114. data/lib/rigor/inference/statement_evaluator.rb +71 -20
  115. data/lib/rigor/inference/string_mutation.rb +9 -3
  116. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  117. data/lib/rigor/inference/version_guard.rb +19 -10
  118. data/lib/rigor/language_server/project_context.rb +31 -0
  119. data/lib/rigor/language_server/server.rb +18 -0
  120. data/lib/rigor/plugin/inflector.rb +1 -1
  121. data/lib/rigor/plugin/io_boundary.rb +42 -3
  122. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  123. data/lib/rigor/project_environment.rb +29 -6
  124. data/lib/rigor/protection/mutation_cache.rb +1 -1
  125. data/lib/rigor/protection/mutator.rb +1 -1
  126. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  127. data/lib/rigor/reflection/constant_path.rb +13 -2
  128. data/lib/rigor/reflection.rb +40 -8
  129. data/lib/rigor/scope/discovery_index.rb +40 -1
  130. data/lib/rigor/scope/resolution_chain.rb +159 -11
  131. data/lib/rigor/scope.rb +189 -10
  132. data/lib/rigor/sig_gen/classification.rb +4 -1
  133. data/lib/rigor/sig_gen/generator.rb +55 -5
  134. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  135. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  136. data/lib/rigor/source/alias_names.rb +52 -0
  137. data/lib/rigor/source/literals.rb +3 -2
  138. data/lib/rigor/source/symbol_name.rb +20 -0
  139. data/lib/rigor/type/combinator.rb +34 -2
  140. data/lib/rigor/type/float_range.rb +4 -1
  141. data/lib/rigor/type/nominal.rb +6 -1
  142. data/lib/rigor/version.rb +1 -1
  143. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  144. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  145. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  146. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  147. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  148. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  149. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  150. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  151. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  152. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  153. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  156. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  157. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  158. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  159. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  160. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  161. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  162. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  163. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  164. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  165. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  166. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  167. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  168. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  169. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  170. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  171. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  172. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  173. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  174. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  175. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  176. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  177. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  178. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  179. data/sig/rigor/scope.rbs +27 -4
  180. data/sig/rigor/source.rbs +4 -0
  181. data/sig/rigor.rbs +3 -1
  182. metadata +66 -1
@@ -391,6 +391,21 @@ module Rigor
391
391
  end
392
392
  private_class_method :plain_constant_path?
393
393
 
394
+ # {read_name} for prebuilt, frozen `"kind:name"` keys: a caller that files the same keys on every read
395
+ # (a resolution chain's hook edges, #1641) builds them once and adds them here without a String apiece.
396
+ def read_keys(keys)
397
+ accumulator = Thread.current[KEY]
398
+ return if accumulator.nil?
399
+
400
+ captures = Thread.current[CAPTURE_KEY]
401
+ open = captures && !captures.empty?
402
+ missing = accumulator.missing
403
+ keys.each do |entry|
404
+ tee_missing(captures, entry) if open
405
+ missing.add(entry)
406
+ end
407
+ end
408
+
394
409
  # The miss-only spelling of {read_name}: a cross-file lookup of `name` (kind `:method` / `:class` /
395
410
  # `:toplevel` / …) that resolved to nothing. Kept as the name the internal spec's negative-edge
396
411
  # contract uses.
@@ -42,6 +42,9 @@ module Rigor
42
42
  @class_to_gem = {}
43
43
  @budget_exceeded = []
44
44
  @gem_modes = {}
45
+ @refinements = {}
46
+ @bound_constants = Set.new
47
+ @bound_segments = Set.new
45
48
  end
46
49
 
47
50
  def absorb(outcome, budget, builder)
@@ -58,6 +61,9 @@ module Rigor
58
61
  @gem_modes[resolved.gem_name] = resolved.mode
59
62
  walked = builder.walker_outcome_for(resolved, budget)
60
63
  @catalog.merge!(walked.catalog)
64
+ @refinements = Inference::ScopeIndexer.merge_refinement_tables(@refinements, walked.refinements)
65
+ @bound_constants.merge(walked.bound)
66
+ @bound_segments.merge(walked.bound_segments)
61
67
  builder.record_class_to_gem(walked.catalog, resolved.gem_name, @class_to_gem)
62
68
  @budget_exceeded << resolved.gem_name if walked.truncated?
63
69
  end
@@ -68,7 +74,8 @@ module Rigor
68
74
  method_catalog: @catalog, budget_exceeded: @budget_exceeded,
69
75
  class_to_gem: @class_to_gem,
70
76
  budget_overrun_strategy: dependencies.budget_overrun_strategy,
71
- gem_modes: @gem_modes
77
+ gem_modes: @gem_modes, refinements: @refinements, bound_constants: @bound_constants,
78
+ bound_segments: @bound_segments
72
79
  )
73
80
  end
74
81
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../cache/descriptor"
4
+
3
5
  module Rigor
4
6
  module Analysis
5
7
  module DependencySourceInference
@@ -19,6 +21,11 @@ module Rigor
19
21
  def descriptor_key
20
22
  [gem_name, version, mode].freeze
21
23
  end
24
+
25
+ # ADR-10 slice 3 — this gem's row of a cache descriptor: its name, resolved version and mode.
26
+ def dependency_entry
27
+ Cache::Descriptor::DependencyEntry.new(gem_name: gem_name, gem_version: version, mode: mode)
28
+ end
22
29
  end
23
30
 
24
31
  # Unresolvable reasons. `:not_in_bundle` covers both the "RubyGems doesn't know this gem" case and the
@@ -43,6 +50,20 @@ module Rigor
43
50
  )
44
51
  end
45
52
 
53
+ # Issue #1814 — the {Resolved#dependency_entry} of each non-disabled `dependencies.source_inference` entry that
54
+ # resolves, in configuration order, without walking any gem. The run-result cache key and the incremental
55
+ # snapshot's fingerprint are built before analysis from inputs that are cheap to read, and a gem's walked
56
+ # source (its method catalogue, its refinement table, the constants it binds) is an input of the run that no
57
+ # analysed file records; a `Gemfile.lock` digest covers a `bundle update` and nothing else, so a project with no
58
+ # lockfile, or a gem installed beside the bundle, would replay the diagnostics of the gem's previous version.
59
+ # Unresolvable entries have no version to key on and add nothing: the gem appearing later adds its row.
60
+ def dependency_entries(dependencies)
61
+ dependencies.source_inference.reject(&:disabled?).filter_map do |entry|
62
+ resolved = resolve(entry)
63
+ resolved.dependency_entry if resolved.is_a?(Resolved)
64
+ end
65
+ end
66
+
46
67
  # Locator. `Gem.loaded_specs` reflects the bundle (cheap lookup, no filesystem walk); `find_by_name` is
47
68
  # the broader fallback for gems present on the gem path but not yet `require`'d. `Gem::MissingSpecError`
48
69
  # is a `LoadError` subclass, so the rescue covers both missing-spec and load-error signals.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../cache/descriptor"
4
+ require_relative "../../inference/scope_indexer"
4
5
 
5
6
  module Rigor
6
7
  module Analysis
@@ -11,7 +12,8 @@ module Rigor
11
12
  # entries.
12
13
  class Index
13
14
  attr_reader :resolved_gems, :unresolvable, :method_catalog, :budget_exceeded,
14
- :class_to_gem, :budget_overrun_strategy, :gem_modes
15
+ :class_to_gem, :budget_overrun_strategy, :gem_modes, :refinements, :bound_constants,
16
+ :bound_segments
15
17
 
16
18
  # @param method_catalog — => Symbol}] the flat
17
19
  # `(class_name, method_name) → :instance | :singleton` table produced by {Walker.walk}, aggregated
@@ -30,10 +32,21 @@ module Rigor
30
32
  # gem-source and RBS both contribute under `mode: :full`. The map is keyed on `gem_name` (not
31
33
  # class) because re-opened classes belong to the first gem they appeared in per `class_to_gem`;
32
34
  # `mode_for(class_name)` chains the two lookups.
33
- def initialize(
35
+ # @param refinements — issue #1672: the opt-in gems' `refine X do … end` bodies, `{refined class =>
36
+ # {method => [refining modules]}}` ({Walker::Outcome#refinements}). The runner merges it into the
37
+ # project's `discovered_refinements` seed, so `using M` for a gem's `M` reaches the check rules.
38
+ # @param bound_constants — issue #1814: every name the opt-in gems' files bind by a constant write without
39
+ # declaring a class or module by it ({Walker::Outcome#bound}: `Shared::Target` for `module Shared; Target =
40
+ # String; end`), unioned across the walked gems. A refine key one of them binds may name any value
41
+ # (`refine Shared::Target` in the project or in the next gem), which the reader decides at read time
42
+ # ({Inference::InEffectRefinements.class_wildcard_key?}) through {#bound_constant?}.
43
+ # @param bound_segments — the last segment of each such write whose owner the walk cannot spell
44
+ # ({Walker::Outcome#bound_segments}: a `const_set`), which matches that segment of any key.
45
+ def initialize( # rubocop:disable Metrics/ParameterLists
34
46
  resolved_gems: [], unresolvable: [], method_catalog: {},
35
47
  budget_exceeded: [], class_to_gem: {},
36
- budget_overrun_strategy: :walker_cap, gem_modes: {}
48
+ budget_overrun_strategy: :walker_cap, gem_modes: {}, refinements: {}, bound_constants: Set.new,
49
+ bound_segments: Set.new
37
50
  )
38
51
  @resolved_gems = resolved_gems.freeze
39
52
  @unresolvable = unresolvable.freeze
@@ -42,6 +55,9 @@ module Rigor
42
55
  @class_to_gem = class_to_gem.freeze
43
56
  @budget_overrun_strategy = budget_overrun_strategy
44
57
  @gem_modes = gem_modes.freeze
58
+ @refinements = Inference::ScopeIndexer.freeze_refinements(refinements)
59
+ @bound_constants = bound_constants.to_set.freeze
60
+ @bound_segments = bound_segments.to_set.freeze
45
61
  freeze
46
62
  end
47
63
 
@@ -62,6 +78,22 @@ module Rigor
62
78
  @class_to_gem[class_name]
63
79
  end
64
80
 
81
+ # Issue #1814 — does a walked gem bind `name` (a refine key, or a prefix of one: `Shared::Target`, `Pag::Enc`)
82
+ # by a constant write that declares no class or module, or bind a constant of `name`'s last segment under an
83
+ # owner the walk could not spell? Such a key may name any value (`module Shared; Target = String; end` then
84
+ # `refine(Shared::Target)`), which the walker cannot resolve and the reader decides
85
+ # ({Inference::InEffectRefinements.class_wildcard_key?}). A gem constant that merely shares a core class's name
86
+ # (`Names::String`) binds nothing a sibling spelling of `refine String` (`Ext::String`) could denote.
87
+ def bound_constant?(name)
88
+ @bound_constants.include?(name) || @bound_segments.include?(name.split("::").last)
89
+ end
90
+
91
+ # Does any walked gem bind a constant at all? False for a project that opts in no gem, so a reader asks nothing
92
+ # per key.
93
+ def binds_constants?
94
+ !(@bound_constants.empty? && @bound_segments.empty?)
95
+ end
96
+
65
97
  # ADR-10 slice 5c — per-class mode lookup. Chains `class_to_gem` + `gem_modes`; returns `nil` when
66
98
  # the class isn't owned by any opt-in gem in this run.
67
99
  def mode_for(class_name)
@@ -99,14 +131,7 @@ module Rigor
99
131
  # surfaces them as `dynamic.dependency-source.gem-not-found` diagnostics. Resolved-but-disabled
100
132
  # entries are also absent: the {Builder} skips them before resolution, so they never reach the index.
101
133
  def cache_descriptor
102
- dependencies = @resolved_gems.map do |resolved|
103
- Cache::Descriptor::DependencyEntry.new(
104
- gem_name: resolved.gem_name,
105
- gem_version: resolved.version,
106
- mode: resolved.mode
107
- )
108
- end
109
- Cache::Descriptor.new(dependencies: dependencies)
134
+ Cache::Descriptor.new(dependencies: @resolved_gems.map(&:dependency_entry))
110
135
  end
111
136
  end
112
137
 
@@ -5,6 +5,7 @@ require "prism"
5
5
  require_relative "return_type_heuristic"
6
6
  require_relative "../../source/constant_path"
7
7
  require_relative "../../source/node_children"
8
+ require_relative "../../inference/scope_indexer"
8
9
 
9
10
  module Rigor
10
11
  module Analysis
@@ -35,10 +36,47 @@ module Rigor
35
36
  # `budget`, and `truncated?` reports whether the cap was reached. The Index records this per-gem so
36
37
  # the Runner can surface a single `dynamic.dependency-source.budget-exceeded` warning naming the
37
38
  # affected gem(s).
38
- class Outcome < Data.define(:catalog, :truncated)
39
+ #
40
+ # Issue #1672 — `refinements` is the gem's `refine X do … end` table, `{refined class => {method =>
41
+ # [refining modules]}}`, the shape `Inference::ScopeIndexer` builds for project files. A refine-body `def`
42
+ # goes there and never into `catalog`: it is not a method of the refining module, and of the refined class
43
+ # only after `using`. ADR-121 WD7 (issue #1814) — the table is the one the project walk's census records for
44
+ # the same source ({Inference::ScopeIndexer.refinement_table}), file by file, so it carries the wildcard rows
45
+ # a project module's would.
46
+ #
47
+ # Issue #1814 — `bound` is every name the gem's files bind by a constant write (an assignment, an operator
48
+ # write, a multiple assignment, a rooted path write) without declaring a class or module by it, spelled under
49
+ # the lexical nesting the write sits in (`Shared::Target` for `module Shared; Target = String; end`);
50
+ # `bound_segments` is the last segment of each write whose owner the walk cannot spell (a path write that is
51
+ # not rooted, a write under `class << …`, a `const_set` with a literal name) ({.collect_bound_constants}). A
52
+ # refine key one of these binds (`refine(Shared::Target)` anywhere) may name any value, which the walker cannot
53
+ # resolve: the rows stay as the census records them, {Index#bound_constant?} unions the names across the walked
54
+ # gems, and the reader decides the key at read time, by each prefix of it
55
+ # ({Inference::InEffectRefinements.class_wildcard_key?}).
56
+ class Outcome < Data.define(:catalog, :truncated, :refinements, :bound, :bound_segments)
57
+ def initialize(catalog:, truncated:, refinements: {}.freeze, bound: Set.new.freeze,
58
+ bound_segments: Set.new.freeze)
59
+ super
60
+ end
61
+
39
62
  def truncated? = truncated
40
63
  end
41
64
 
65
+ # The walk's accumulators. The budget counts `catalog` only, but stops the whole walk: a file reached after it
66
+ # trips is not read. `root` is the file being walked and `in_effect` its {Inference::InEffectRefinements}, built
67
+ # the first time a `refine`-shaped call asks (issue #1689). Issue #1814 — `bound` and `bound_segments` are
68
+ # {Outcome}'s.
69
+ Harvest = Struct.new(:catalog, :refinements, :root, :in_effect, :bound, :bound_segments) do
70
+ # {Inference::ScopeIndexer.module_refine_target} against this file's in-effect refinements.
71
+ def refine_target_of(node)
72
+ return nil if Inference::ScopeIndexer.refine_target(node).nil?
73
+
74
+ self.in_effect ||= Inference::InEffectRefinements.new(root)
75
+ Inference::ScopeIndexer.module_refine_target(node, in_effect)
76
+ end
77
+ end
78
+ private_constant :Harvest
79
+
42
80
  # Per-method catalog entry. `kind` is `:instance` or `:singleton`; `return_type` is the
43
81
  # {ReturnTypeHeuristic}-extracted static facet (a `Rigor::Type::*`) or `nil` when the heuristic
44
82
  # declined. The dispatcher wraps a non-nil `return_type` in `Dynamic[T]`; a `nil` `return_type` falls
@@ -65,14 +103,16 @@ module Rigor
65
103
  # the budget was reached. Methods of identical name on the same class with different kinds (rare;
66
104
  # private API mostly) carry the kind that wins the per-class first walk.
67
105
  def walk(gem_dir:, roots:, budget: UNBOUNDED)
68
- accumulator = {}
106
+ harvest = Harvest.new({}, {}, nil, nil, Set.new, Set.new)
69
107
  truncated = false
70
108
  accepted_roots(roots).each do |root|
71
109
  break if truncated
72
110
 
73
- truncated = walk_root(File.join(gem_dir.to_s, root), accumulator, budget)
111
+ truncated = walk_root(File.join(gem_dir.to_s, root), harvest, budget)
74
112
  end
75
- Outcome.new(catalog: accumulator.freeze, truncated: truncated)
113
+ Outcome.new(catalog: harvest.catalog.freeze, truncated: truncated,
114
+ refinements: Inference::ScopeIndexer.freeze_refinements(harvest.refinements),
115
+ bound: harvest.bound.freeze, bound_segments: harvest.bound_segments.freeze)
76
116
  end
77
117
 
78
118
  # Drops hard-excluded entries before any filesystem walk happens. Reasoning: we never want a gem's
@@ -84,21 +124,25 @@ module Rigor
84
124
 
85
125
  # Returns true when the budget tripped during this root's walk so the caller can stop iterating
86
126
  # subsequent roots.
87
- def walk_root(root_dir, accumulator, budget) # rubocop:disable Naming/PredicateMethod
127
+ def walk_root(root_dir, harvest, budget) # rubocop:disable Naming/PredicateMethod
88
128
  return false unless File.directory?(root_dir)
89
129
 
90
130
  Dir.glob(File.join(root_dir, "**", "*.rb")).each do |path|
91
- harvest_file(path, accumulator, budget)
92
- return true if accumulator.size >= budget
131
+ harvest_file(path, harvest, budget)
132
+ return true if harvest.catalog.size >= budget
93
133
  end
94
134
  false
95
135
  end
96
136
 
97
- def harvest_file(path, accumulator, budget)
137
+ def harvest_file(path, harvest, budget)
98
138
  parse_result = Prism.parse_file(path)
99
139
  return unless parse_result.errors.empty?
100
140
 
101
- walk_node(parse_result.value, [], false, accumulator, budget)
141
+ harvest.root = parse_result.value
142
+ harvest.in_effect = nil
143
+ record_refinements(parse_result.value, path, harvest)
144
+ collect_bound_constants(parse_result.value, [[]], harvest)
145
+ walk_node(parse_result.value, [], false, harvest, budget)
102
146
  rescue StandardError
103
147
  # Gem source we can't parse / read silently degrades to "no contribution from this file". The
104
148
  # user-facing diagnostic stream is reserved for the project source; opt-in gem source MUST NOT
@@ -106,66 +150,191 @@ module Rigor
106
150
  nil
107
151
  end
108
152
 
153
+ # ADR-121 WD7 (issue #1814) — the refinement table the file contributes, read by the census the project walk
154
+ # runs ({Inference::ScopeIndexer.refinement_table}): the `refine` bodies, the computed targets, the `refine`
155
+ # inside a method, the `:refine` literals, and the wildcard rows each leaves, folded as the project folds its
156
+ # files' tables (`merge_refinement_tables`). The walker's own descent below records no row.
157
+ def record_refinements(root, path, harvest)
158
+ harvest.refinements = Inference::ScopeIndexer.merge_refinement_tables(
159
+ harvest.refinements, Inference::ScopeIndexer.refinement_table(root, path)
160
+ )
161
+ end
162
+
163
+ # Issue #1814 — the names the file binds by a constant write without declaring a class or module by them, or
164
+ # the last segment alone where the walk cannot spell the owner Ruby binds. A bare write (`K = …`, `K ||= …`,
165
+ # `K += …`, a multiple-assignment target) binds the name under the lexical nesting of the `class` / `module`
166
+ # headers around it, which a block does not change (a `Module.new do … end` or `module_eval { … }` body assigns
167
+ # a constant in the lexical scope around it); `prefixes` holds every nesting the headers can denote, since a
168
+ # nested compact header opens whichever module Ruby finds (`module W; module Shared::Inner` is
169
+ # `W::Shared::Inner` or `Shared::Inner`), as the census spells a refine target. A write whose value is one of
170
+ # the class-creating shapes the project walk registers as a declaration (`Class.new`, `Module.new`,
171
+ # `Struct.new`, `Data.define`, a `.freeze` tail or a `K = K || …` guard stripped:
172
+ # {Inference::ScopeIndexer.meta_new_rvalue}) declares a class, so `Point = Struct.new(:x); refine(Point)` keeps
173
+ # a normal row. Where the owner cannot be spelled the segment alone is recorded, which matches that segment of
174
+ # any key: a write under `class << …` (the singleton's), a path write that is not rooted (`Wrap::K = …` may
175
+ # bind under an included module's `Wrap`; `self::K = …` in a block binds under the block's `self`), and a
176
+ # `const_set` with a literal name (`Object.const_set(:K, …)` binds `::K`). A rooted path (`::Shared::K = …`)
177
+ # binds that name. One pass over the tree; the rows are not touched.
178
+ def collect_bound_constants(node, prefixes, harvest)
179
+ return unless node.is_a?(Prism::Node)
180
+
181
+ case node
182
+ when Prism::ClassNode, Prism::ModuleNode then prefixes = header_prefixes(node.constant_path, prefixes)
183
+ when Prism::SingletonClassNode then prefixes = nil
184
+ when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode,
185
+ Prism::ConstantOperatorWriteNode
186
+ note_bare_write(node.name.to_s, prefixes, harvest) unless declares_class?(node)
187
+ when Prism::ConstantTargetNode then note_bare_write(node.name.to_s, prefixes, harvest)
188
+ when Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode,
189
+ Prism::ConstantPathOperatorWriteNode
190
+ note_path_write(node.target, harvest) unless declares_class?(node)
191
+ when Prism::ConstantPathTargetNode then note_path_write(node, harvest)
192
+ when Prism::CallNode then note_const_set(node, harvest)
193
+ end
194
+ node.rigor_each_child { |child| collect_bound_constants(child, prefixes, harvest) }
195
+ end
196
+
197
+ def declares_class?(write)
198
+ rvalue = Inference::ScopeIndexer.meta_new_rvalue(write)
199
+ !rvalue.nil? && Inference::ScopeIndexer.meta_new_constant_rvalue?(rvalue)
200
+ end
201
+
202
+ # The nestings a `class` / `module` body may sit in, given the ones its header is written in: a bare header
203
+ # opens a module under each; a compact header (`module Shared::Inner`) opens `Inner` under every name its base
204
+ # can denote there ({Inference::ScopeIndexer.constant_receiver_candidates}); a rooted header resets them. nil,
205
+ # the unspellable owner, for a header no constant spells or one under `class << …`.
206
+ def header_prefixes(constant_path, prefixes)
207
+ return nil if prefixes.nil? || Source::ConstantPath.qualified_name_or_nil(constant_path).nil?
208
+ return [Source::ConstantPath.qualified_name_or_nil(constant_path).split("::")] if rooted_path?(constant_path)
209
+
210
+ name = constant_path.name.to_s
211
+ return prefixes.map { |prefix| prefix + [name] } if constant_path.is_a?(Prism::ConstantReadNode)
212
+
213
+ prefixes.flat_map do |prefix|
214
+ Inference::ScopeIndexer.constant_receiver_candidates(constant_path.parent, prefix)
215
+ .map { |owner| owner.split("::") + [name] }
216
+ end.uniq
217
+ end
218
+
219
+ # `::K`, `::A::K`, as a path or a multiple-assignment target: the leftmost segment has no parent.
220
+ def rooted_path?(path)
221
+ path.respond_to?(:parent) && (path.parent.nil? || Source::ConstantPath.rooted?(path.parent))
222
+ end
223
+
224
+ def note_bare_write(name, prefixes, harvest)
225
+ return harvest.bound_segments << name if prefixes.nil?
226
+
227
+ prefixes.each { |prefix| harvest.bound << (prefix + [name]).join("::") }
228
+ end
229
+
230
+ # A path write binds its name where its base resolves, which the walk can spell only for a rooted base.
231
+ def note_path_write(path, harvest)
232
+ name = path.name.to_s
233
+ return harvest.bound_segments << name unless rooted_path?(path)
234
+
235
+ owner = path.parent && Source::ConstantPath.qualified_name_or_nil(path.parent)
236
+ harvest.bound << (owner ? "#{owner}::#{name}" : name)
237
+ end
238
+
239
+ # `const_set(:K, value)` / `const_set("K", value)` with a literal name, on any receiver: the owner is a value.
240
+ # A class-creating value declares a class, as a write of one does.
241
+ def note_const_set(node, harvest)
242
+ return unless node.name == :const_set
243
+
244
+ name, value = node.arguments&.arguments
245
+ return unless name.is_a?(Prism::SymbolNode) || name.is_a?(Prism::StringNode)
246
+ return if value && Inference::ScopeIndexer.meta_new_constant_rvalue?(value)
247
+
248
+ harvest.bound_segments << name.unescaped
249
+ end
250
+
109
251
  # Walks a Prism subtree, accumulating method definitions under their qualified class name. Mirrors
110
252
  # the shape of `Inference::ScopeIndexer#walk_methods` but stays decoupled from `Scope` because
111
253
  # gem-source inference runs without a scope context.
112
- def walk_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
254
+ def walk_node(node, qualified_prefix, in_singleton_class, harvest, budget)
113
255
  return unless node.is_a?(Prism::Node)
114
- return if accumulator.size >= budget
256
+ return if harvest.catalog.size >= budget
115
257
 
116
258
  case node
117
259
  when Prism::ClassNode, Prism::ModuleNode
118
- descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
260
+ descend_class_or_module(node, qualified_prefix, in_singleton_class, harvest, budget)
119
261
  when Prism::SingletonClassNode
120
- descend_singleton_class(node, qualified_prefix, accumulator, budget)
262
+ descend_singleton_class(node, qualified_prefix, harvest, budget)
121
263
  when Prism::DefNode
122
- record_def_node(node, qualified_prefix, in_singleton_class, accumulator, budget)
264
+ record_def_node(node, qualified_prefix, in_singleton_class, harvest, budget)
265
+ when Prism::CallNode
266
+ return walk_refine_body(node, qualified_prefix, harvest, budget) if harvest.refine_target_of(node)
267
+
268
+ walk_children(node, qualified_prefix, in_singleton_class, harvest, budget)
123
269
  else
124
- walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
270
+ walk_children(node, qualified_prefix, in_singleton_class, harvest, budget)
125
271
  end
126
272
  end
127
273
 
128
- def walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
274
+ def walk_children(node, qualified_prefix, in_singleton_class, harvest, budget)
129
275
  node.rigor_each_child do |child|
130
- break if accumulator.size >= budget
276
+ break if harvest.catalog.size >= budget
131
277
 
132
- walk_node(child, qualified_prefix, in_singleton_class, accumulator, budget)
278
+ walk_node(child, qualified_prefix, in_singleton_class, harvest, budget)
133
279
  end
134
280
  end
135
281
 
136
282
  # `class Foo` / `module Bar`. The dynamic-prefix shape (`module ::Foo`-rooted variants whose left
137
283
  # side is a runtime expression) is treated as opaque — we walk the children under the same prefix
138
284
  # so any inner class definitions are still recorded under their own name.
139
- def descend_class_or_module(node, qualified_prefix, in_singleton_class, accumulator, budget)
285
+ def descend_class_or_module(node, qualified_prefix, in_singleton_class, harvest, budget)
140
286
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
141
287
  if name && node.body
142
288
  child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
143
- walk_node(node.body, child_prefix, in_singleton_class, accumulator, budget)
289
+ walk_node(node.body, child_prefix, in_singleton_class, harvest, budget)
144
290
  else
145
- walk_children(node, qualified_prefix, in_singleton_class, accumulator, budget)
291
+ walk_children(node, qualified_prefix, in_singleton_class, harvest, budget)
146
292
  end
147
293
  end
148
294
 
149
295
  # `class << self` only — `class << expr` for any other `expr` is treated as opaque so we don't
150
296
  # accidentally record per-instance singleton methods under the surrounding class.
151
- def descend_singleton_class(node, qualified_prefix, accumulator, budget)
297
+ def descend_singleton_class(node, qualified_prefix, harvest, budget)
152
298
  if node.expression.is_a?(Prism::SelfNode) && node.body
153
- walk_node(node.body, qualified_prefix, true, accumulator, budget)
299
+ walk_node(node.body, qualified_prefix, true, harvest, budget)
154
300
  else
155
- walk_children(node, qualified_prefix, false, accumulator, budget)
301
+ walk_children(node, qualified_prefix, false, harvest, budget)
156
302
  end
157
303
  end
158
304
 
159
- def record_def_node(node, qualified_prefix, in_singleton_class, accumulator, _budget)
305
+ def record_def_node(node, qualified_prefix, in_singleton_class, harvest, _budget)
160
306
  return if qualified_prefix.empty?
161
307
 
162
308
  class_name = qualified_prefix.join("::")
163
309
  kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
164
310
  key = [class_name, node.name]
165
- return if accumulator.key?(key) # first walk wins
311
+ return if harvest.catalog.key?(key) # first walk wins
166
312
 
167
313
  return_type = ReturnTypeHeuristic.extract(node)
168
- accumulator[key] = CatalogEntry.new(kind: kind, return_type: return_type)
314
+ harvest.catalog[key] = CatalogEntry.new(kind: kind, return_type: return_type)
315
+ end
316
+
317
+ # Issue #1672 — `refine X do … end`, the shape {Inference::ScopeIndexer.module_refine_target} accepts (not one
318
+ # whose `self` is a class: that is the class's own method and walks as any other call, issue #1689). The body's
319
+ # instance `def`s are refinements of X by the enclosing module, and none reaches the catalogue. The refinement
320
+ # table is not built here: {.record_refinements} runs the project walk's census over the whole file, which reads
321
+ # this body and every other `refine`-shaped node (ADR-121 WD7, issue #1814). Declarations nested in the body
322
+ # still walk under the lexical prefix, as they did before.
323
+ def walk_refine_body(node, qualified_prefix, harvest, budget)
324
+ body = node.block.body
325
+ walk_refine_declarations(body, qualified_prefix, harvest, budget) unless body.nil?
326
+ end
327
+
328
+ # The class / module declarations inside a refine body, walked as before; nothing else in it is.
329
+ def walk_refine_declarations(node, qualified_prefix, harvest, budget)
330
+ case node
331
+ when Prism::ClassNode, Prism::ModuleNode
332
+ walk_node(node, qualified_prefix, false, harvest, budget)
333
+ when Prism::DefNode, Prism::SingletonClassNode
334
+ nil
335
+ else
336
+ node.rigor_each_child { |child| walk_refine_declarations(child, qualified_prefix, harvest, budget) }
337
+ end
169
338
  end
170
339
  end
171
340
  end
@@ -25,7 +25,8 @@ module Rigor
25
25
  source = parse_result.source.source
26
26
  return false unless source.is_a?(String) && !source.empty?
27
27
 
28
- ERB_CLOSING_MARKER.match?(source)
28
+ # The marker is ASCII; match bytes so an invalid byte elsewhere in the file cannot raise.
29
+ ERB_CLOSING_MARKER.match?(source.b)
29
30
  end
30
31
  end
31
32
  end
@@ -120,18 +120,22 @@ module Rigor
120
120
  appeared.freeze
121
121
  end
122
122
 
123
- # Issue #1120 — the method names whose refinements moved in this edit: a `[refined class, method, refining
124
- # module]` entry that appeared in, or vanished from, any file in `paths`. A consumer whose
125
- # `call.undefined-method` answer consulted the refinement table recorded a `refinement:<method>` edge
126
- # whichever way it answered, because the answer is a function of the whole project's refinements of that
127
- # name. `paths` MUST include removed files, whose whole before-table vanished. `before` / `after` map a
128
- # path to that file's `{refined class => {method => [modules]}}` table (nil when it refines nothing).
123
+ # Issue #1120 — the method names whose refinements moved in this edit. A consumer whose answer consulted the
124
+ # refinement table recorded a `refinement:<method>` edge whichever way it answered, because the answer is a
125
+ # function of the whole project's refinements of that name. `paths` MUST include removed files, whose whole
126
+ # before-table vanished. `before` / `after` map a path to that file's `{refined class => {method =>
127
+ # [modules]}}` table (nil when it refines nothing).
128
+ #
129
+ # Issue #1664 — every name a changed file refines, before or after the edit, not only the entries that
130
+ # appeared or vanished: the typed refined arm answers with a refine body's inferred return, which an edit
131
+ # inside the body changes while the table stays the same. A changed file's refinements are therefore all
132
+ # re-checked, which over-reports an edit elsewhere in the file and costs only the consumers of its names.
129
133
  def changed_refinement_names(paths, before, after)
130
134
  moved = Set.new
131
135
  paths.each do |path|
132
- entries_before = refinement_entries(before[path])
133
- entries_after = refinement_entries(after[path])
134
- (entries_before ^ entries_after).each { |(_refined, method_name, _module)| moved << method_name }
136
+ (refinement_entries(before[path]) | refinement_entries(after[path])).each do |(_refined, method_name, _)|
137
+ moved << method_name
138
+ end
135
139
  end
136
140
  moved.freeze
137
141
  end
@@ -146,6 +150,22 @@ module Rigor
146
150
  entries
147
151
  end
148
152
 
153
+ # Issue #1820 — adds to `per_file` (`{path => Set}`) a frozen `[class, superclass as written]` pair in each file
154
+ # that declares a class with a superclass, so {changed_class_declarations} reports a subclass edge appearing or
155
+ # vanishing beside the class names.
156
+ def add_superclass_edges(per_file, class_sources, superclasses)
157
+ return per_file if superclasses.nil? || superclasses.empty?
158
+
159
+ superclasses.each do |child, parent|
160
+ edge = [child, parent.nil? ? "*" : parent.to_s].freeze
161
+ (class_sources[child] || EMPTY_FILES).each { |file| per_file[file] << edge }
162
+ end
163
+ per_file
164
+ end
165
+
166
+ EMPTY_FILES = [].freeze
167
+ private_constant :EMPTY_FILES
168
+
149
169
  # Issue #639 — the SYMMETRIC reading of {appeared_classes}: the names whose declaration in a file
150
170
  # appeared OR VANISHED. Appearance alone answered the miss side, which was the only side recording a
151
171
  # `class:` edge; a bare reference that RESOLVED now records one too, and what invalidates it is the