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
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
 
6
6
  require_relative "../incremental_run_slot"
7
+ require_relative "../dependency_source_inference/gem_resolver"
7
8
  require_relative "../../cache/file_digest"
8
9
  require_relative "../../cache/incremental_snapshot"
9
10
 
@@ -61,7 +62,9 @@ module Rigor
61
62
  # signature file under the configured or auto-detected `sig/` roots, or a `pre_eval:` file, changed in
62
63
  # between would leave the snapshot the run restores keyed by one tree
63
64
  # and the run reading another. From the stamp on, the lockfiles are the guard's to watch ({#admits?}), which
64
- # is also what lets the slot's key, whose only file inputs they are, be computed when the run ends.
65
+ # is also what lets the slot's key, whose only file inputs they are, be computed when the run ends. The
66
+ # opted-in gems' resolved versions, the key's one input that is neither a file nor the configuration (issue
67
+ # #1814), are read at the mark and watched the same way.
65
68
  #
66
69
  # The mark also records which of the paths an existence row can name are present: the lockfiles, the
67
70
  # analysis roots, the `pre_eval:` entries and the signature roots.
@@ -78,10 +81,22 @@ module Rigor
78
81
  presence = (lockfiles + named.map { |path| File.absolute_path(path.to_s) }).to_h do |path|
79
82
  [path, File.exist?(path)]
80
83
  end
84
+ rows = dependency_rows(configuration)
85
+ return nil if rows.nil?
86
+
81
87
  new(started_ns: started_ns, device: device, lockfiles: lockfiles, presence: presence,
82
- stamp_dir: stamp_dir(cache_root))
88
+ stamp_dir: stamp_dir(cache_root), configuration: configuration, dependency_rows: rows)
83
89
  end
84
90
 
91
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows as the key carries them; nil when they cannot
92
+ # be resolved, which takes no mark (the key could not be computed either).
93
+ def self.dependency_rows(configuration)
94
+ DependencySourceInference::GemResolver.dependency_entries(configuration.dependencies)
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ private_class_method :dependency_rows
99
+
85
100
  # The mark: the change time and device of the first stamp whose change time is later than that of a
86
101
  # stamp taken before it, on the same device; nil when none is written within {TICK_WAIT_LIMIT}.
87
102
  def self.stamp(cache_root)
@@ -143,17 +158,21 @@ module Rigor
143
158
  end
144
159
  private_class_method :lockfile_paths
145
160
 
146
- def initialize(started_ns:, device:, lockfiles:, presence: {}, stamp_dir: nil)
161
+ def initialize(started_ns:, device:, lockfiles:, presence: {}, stamp_dir: nil, configuration: nil,
162
+ dependency_rows: [])
147
163
  @stamp_dir = stamp_dir
148
164
  @started_ns = started_ns
149
165
  @device = device
150
166
  @lockfiles = lockfiles
151
167
  @presence = presence
168
+ @configuration = configuration
169
+ @dependency_rows = dependency_rows
152
170
  end
153
171
 
154
172
  # True when nothing the guard watches moved since the mark:
155
173
  #
156
- # - no lockfile the key or the fingerprint digests was written, created or removed;
174
+ # - no lockfile the key or the fingerprint digests was written, created or removed, and no opted-in gem
175
+ # resolves to another version than at the mark (issue #1814);
157
176
  # - no file `rows` or `pinned` names changed. A content row's file changed if its change time moved. An
158
177
  # existence row asks only whether its path is there, so a path the mark recorded changed if it came or
159
178
  # went, and an editor's lock file created and removed beside it, which moves its directory's change
@@ -174,7 +193,7 @@ module Rigor
174
193
  # @param rows — a {Cache::Descriptor} of the rows the key does not pin
175
194
  # @param pinned — a {Cache::Descriptor} of the rows it does
176
195
  def admits?(rows, pinned: Cache::Descriptor.new)
177
- !clock_stepped_back? && @lockfiles.none? { |path| lockfile_changed?(path) } &&
196
+ !clock_stepped_back? && @lockfiles.none? { |path| lockfile_changed?(path) } && dependency_rows_unchanged? &&
178
197
  files_unchanged?(rows.files, strict: true) && globs_unchanged?(rows.globs, strict: true) &&
179
198
  files_unchanged?(pinned.files, strict: false) && globs_unchanged?(pinned.globs, strict: false)
180
199
  rescue StandardError
@@ -196,6 +215,12 @@ module Rigor
196
215
 
197
216
  private
198
217
 
218
+ # Issue #1814 — the opted-in gems resolve to the versions they did at the mark. A guard built without a
219
+ # configuration (the specs' bare constructor) watches none.
220
+ def dependency_rows_unchanged?
221
+ @configuration.nil? || self.class.send(:dependency_rows, @configuration) == @dependency_rows
222
+ end
223
+
199
224
  # One more stamp: a change time earlier than the mark means the clock stepped back since it was taken, so
200
225
  # a save since then may carry a change time the guard reads as before it. A stamp that cannot be written,
201
226
  # or lands on another device, cannot vouch for the clock either.
@@ -84,7 +84,9 @@ module Rigor
84
84
  return nil if base.nil?
85
85
 
86
86
  roots = RunCacheKey.config_entry("incremental.roots", normalize_roots(target.roots).sort.join("\n"))
87
- Cache::Descriptor.new(gems: base.gems, configs: base.configs + [roots])
87
+ # Issue #1814 — the opted-in gems' `(name, version, mode)` rows travel with the key, as they do in the plain
88
+ # probe's: the gem's walked source reaches every file's answer and no analysed file records it.
89
+ Cache::Descriptor.new(gems: base.gems, dependencies: base.dependencies, configs: base.configs + [roots])
88
90
  end
89
91
 
90
92
  # The roots as absolute paths, which the KEY holds sorted: `lib`, `./lib` and `lib/` are one root there. A
@@ -1090,12 +1090,24 @@ module Rigor
1090
1090
  changed + removed, @constant_decls, new_constant_decls
1091
1091
  )
1092
1092
  keys = appeared_methods.map { |symbol| negative_key_for(symbol) }
1093
- keys.concat(moved_classes.map { |klass| "class:#{klass.split('::').last}" })
1093
+ keys.concat(class_move_keys(moved_classes))
1094
1094
  keys.concat(moved_constants.map { |name| "constant:#{name.split('::').last}" })
1095
1095
  Incremental.negative_closure(keys, @negative_dependents) | refinement_affected(changed + removed, summary) |
1096
1096
  global_write_reporters
1097
1097
  end
1098
1098
 
1099
+ # The negative keys a class-declaration diff fires: `class:<last segment>` for a class name, and (issue #1820)
1100
+ # `subclass_of:<last segment of the superclass>` for a `[class, superclass]` pair appearing or vanishing. Any
1101
+ # class declaration moving may move the set of classes whose superclass the tables do not know, which
1102
+ # `subclass_of:*` stands for.
1103
+ def class_move_keys(moved)
1104
+ names, edges = moved.partition { |entry| entry.is_a?(String) }
1105
+ keys = names.map { |klass| "class:#{klass.split('::').last}" }
1106
+ keys.concat(edges.map { |(_child, parent)| "subclass_of:#{parent.split('::').last}" })
1107
+ keys << "subclass_of:*" unless moved.empty?
1108
+ keys
1109
+ end
1110
+
1099
1111
  # Issue #1120 — the consumers whose `call.undefined-method` answer read the refinement table for a method
1100
1112
  # name whose refinements moved in this edit. The before-state is each file's seed bundle (the per-file
1101
1113
  # table the last run recorded), the after-state the scan's per-file tables. A refine body is not a class's
@@ -1118,6 +1130,7 @@ module Rigor
1118
1130
  index[:class_sources].each do |class_name, files|
1119
1131
  files.each { |file| result[file] << class_name }
1120
1132
  end
1133
+ Incremental.add_superclass_edges(result, index[:class_sources], index[:superclasses])
1121
1134
  result.transform_values(&:freeze).freeze
1122
1135
  end
1123
1136
 
@@ -35,15 +35,21 @@ module Rigor
35
35
  # a reopened gem or stdlib class must never be a candidate (WD6). Defaults to "nothing is foreign".
36
36
  # @param dynamic_uses — sites where a constant is reached by name at runtime.
37
37
  # A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
38
- def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], foreign: ->(_fqn) { false })
38
+ # @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
39
+ # reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
40
+ def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
41
+ foreign: ->(_fqn) { false })
39
42
  @declarations = declarations
40
- @dynamic_uses = dynamic_uses
41
43
  @references = references + literal_dynamic_references(dynamic_uses)
42
44
  @root_fqns = root_fqns.to_set
43
45
  @foreign = foreign
44
46
  @by_fqn = declarations.group_by(&:fqn)
47
+ @shadows = shadows.group_by(&:path).transform_values { |decls| decls.to_set(&:fqn) }
48
+ @shadows.default = Set.new.freeze
45
49
  @owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
46
50
  @ancestors = {}
51
+ # `Foo.const_get("V#{k}")` bounds its reach by `Foo`, a reference like any other (#1734).
52
+ @dynamic_uses = dynamic_uses.flat_map { |use| use.anchored { |ref| resolve_ref(ref) } }
47
53
  end
48
54
 
49
55
  def report
@@ -51,21 +57,31 @@ module Rigor
51
57
  production = walk(edges, seeds: production_seeds, roles: %i[production task config])
52
58
  reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
53
59
  unreached = @owned - reachable
54
- namespaces = namespace_only(unreached, reachable)
60
+ # A namespace is excused by a member PRODUCTION reaches. One whose members only tests reach is dead
61
+ # production code like them, so it joins them under test-only rather than vanishing from every
62
+ # bucket (#1732).
63
+ namespaces = namespace_only(unreached, production)
64
+ test_only = (reachable - production) | namespace_only(unreached - namespaces, reachable)
65
+ unreached -= namespaces | test_only
66
+ # A namespace a test names (`Ns::CONST`) while production reaches a declaration under it is live in
67
+ # production for the same reason an unreached one is not dead (#1732).
68
+ live_namespaces = namespace_only(test_only, production)
69
+ test_only -= live_namespaces
55
70
  # The data-file demotion applies to BOTH buckets it can speak to, which is what the tier
56
71
  # contract says and what the implementation had narrowed (#370). See {#tainted}.
57
- test_only = reachable - production
58
- undecidable = tainted(unreached - namespaces).merge(tainted(test_only))
59
- build_report(edges: edges, reachable: reachable, unreached: unreached, namespaces: namespaces,
60
- test_only: test_only, undecidable: undecidable)
72
+ undecidable = tainted(unreached).merge(tainted(test_only))
73
+ undecidable = spread_undecidable(edges, undecidable, unreached | test_only,
74
+ through: namespaces | live_namespaces)
75
+ build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
61
76
  end
62
77
 
63
78
  private
64
79
 
65
80
  def build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
66
81
  demoted = undecidable.keys.to_set
67
- Report.new(declared: @owned.size, reachable: reachable.size,
68
- candidates: rows(unreached - namespaces - demoted),
82
+ # A test-only namespace counts as reachable, like the members it is listed with.
83
+ Report.new(declared: @owned.size, reachable: (reachable | test_only).size,
84
+ candidates: rows(unreached - demoted),
69
85
  undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
70
86
  test_only: rows(test_only - demoted),
71
87
  namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
@@ -109,6 +125,41 @@ module Rigor
109
125
  end
110
126
  end
111
127
 
128
+ # Whatever an undecidable declaration reaches is undecidable too (#1720 review). `class Sub < Base` with
129
+ # `Sub` reachable through `"Sub#{x}".constantize` left `Base` a DEFINITE candidate, and acting on that row
130
+ # deletes the base of a class that may well be live. Only declarations that would otherwise be
131
+ # reported — unreached or test-only — are moved; the reason names the declaration they hang from.
132
+ #
133
+ # The spread passes `through` a declaration hidden as a namespace without listing it: a service hidden
134
+ # only because something names a constant nested in it (`Svc::Error`) still carries the undecidable
135
+ # evidence on to what its body names. Stopping there left the next service down under "reachable only
136
+ # from tests" (#1732).
137
+ def spread_undecidable(edges, undecidable, reportable, through:)
138
+ return undecidable if undecidable.empty?
139
+
140
+ out = Hash.new { |h, k| h[k] = [] }
141
+ edges.each { |from, to, _role| out[from] << to }
142
+ result = undecidable.dup
143
+ passed = Set.new
144
+ queue = undecidable.keys
145
+ until queue.empty?
146
+ from = queue.shift
147
+ out[from].each do |target|
148
+ next if result.key?(target) || passed.include?(target)
149
+
150
+ if reportable.include?(target)
151
+ result[target] = "reachable from #{from}, which cannot be decided"
152
+ elsif through.include?(target)
153
+ passed << target
154
+ else
155
+ next
156
+ end
157
+ queue << target
158
+ end
159
+ end
160
+ result
161
+ end
162
+
112
163
  def undecidable_row(fqn, reason)
113
164
  site = @by_fqn.fetch(fqn).first
114
165
  Undecidable.new(fqn: fqn, path: site.path, line: site.line, reason: reason)
@@ -132,14 +183,32 @@ module Rigor
132
183
  # Seeds that make a declaration live in PRODUCTION: named entry points, plus anything referenced at
133
184
  # file level by a non-test file (file-level code runs on load, so its target is live).
134
185
  def production_seeds
135
- @production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| ref.from.nil? && ref.role != :test }
186
+ @production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| source(ref).nil? && ref.role != :test }
136
187
  end
137
188
 
138
189
  # Seeds that make a declaration live only through TEST code. Kept separate from production seeds
139
190
  # rather than folded in: a spec's file-level `Foo.new` would otherwise promote `Foo` to a root and
140
191
  # erase the very distinction WD8 exists to report.
141
192
  def test_seeds
142
- @test_seeds ||= seeds_from { |ref| ref.from.nil? && ref.role == :test }
193
+ @test_seeds ||= seeds_from { |ref| source(ref).nil? && ref.role == :test }
194
+ end
195
+
196
+ # The node an edge leaves: the owned declaration whose code holds the reference, with a declaration-header
197
+ # reference (`class Sub < Base`) credited to `Sub` (#1720). Only an owned declaration from `paths:` is a
198
+ # node, though. A class an initializer, `config/application.rb` or a spec helper declares, or a reopened
199
+ # gem class (WD6), is outside the declaration set, so the walk can never start from it and an edge
200
+ # leaving it would silently drop the evidence. Falling back to the enclosing nesting (#1724) did not help
201
+ # either: `module MyApp; class Application` and a spec's `module Outer; class Fake < OBase` fall back to a
202
+ # module that is never reached itself, or is not a node at all (#1732).
203
+ #
204
+ # Such a reference counts as file-level code of its file instead, in that file's role: a spec helper's
205
+ # reference keeps its target test-reachable, an initializer's keeps it live in production. A class header
206
+ # and a class body's own statements run when the file loads; a method body is read the same way because
207
+ # whether it runs is not decidable from a declaration this report does not own, and the other reading
208
+ # would report live code as dead. What the reference NAMES is still resolved against its own file's
209
+ # declarations first (see {#resolve_ref}).
210
+ def source(ref)
211
+ @owned.include?(ref.from) ? ref.from : nil
143
212
  end
144
213
 
145
214
  def seeds_from
@@ -147,7 +216,7 @@ module Rigor
147
216
  @references.each do |ref|
148
217
  next unless yield(ref)
149
218
 
150
- target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
219
+ target = resolve_ref(ref)
151
220
  set << target if target && @owned.include?(target)
152
221
  end
153
222
  set
@@ -156,14 +225,39 @@ module Rigor
156
225
  # `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
157
226
  def resolved_edges
158
227
  @resolved_edges ||= @references.filter_map do |ref|
159
- target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
228
+ target = resolve_ref(ref)
160
229
  next unless target && @owned.include?(target)
161
- next if ref.from == target # a declaration referencing itself is not evidence of use
162
230
 
163
- [ref.from, target, ref.role]
231
+ from = source(ref)
232
+ next if from == target # a declaration referencing itself is not evidence of use
233
+
234
+ [from, target, ref.role]
164
235
  end
165
236
  end
166
237
 
238
+ # A declaration-header reference never names the declaration it belongs to: `module Api; class User <
239
+ # User; end; end` reads `User` before `Api::User` exists, so Ruby answers `::User`. Every other reference
240
+ # resolves as written, and one that lands on its own declaration is dropped as a self-reference.
241
+ #
242
+ # A class its own file declares outside `paths:` shadows a same-named owned one, as Ruby's lookup does:
243
+ # `LegacyThing` inside a migration's local `class LegacyThing < ActiveRecord::Base` stub names the stub,
244
+ # not the app's model. The answer is then not an owned node and the reference is dropped. Only the
245
+ # referring file's own declarations count: a spec support file's stub is loaded with the specs, never
246
+ # with production, so letting it shadow other files could hide a production reference (#1732).
247
+ def resolve_ref(ref)
248
+ local = @shadows[ref.path]
249
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted, local: local)
250
+ return target unless target && target == ref.from && header?(ref)
251
+
252
+ resolve(ref.as_written, ref.nesting, rooted: ref.rooted, exclude: target, local: local)
253
+ end
254
+
255
+ # Only a header reference is credited to something other than the scope it is written in (see
256
+ # {Scan::Reference}).
257
+ def header?(ref)
258
+ ref.from != (ref.nesting.empty? ? nil : ref.nesting.join("::"))
259
+ end
260
+
167
261
  # Mark-and-sweep, not reference counting: an edge only propagates if its SOURCE is itself reachable, so
168
262
  # a cluster of mutually-referencing dead classes stays dead (ADR-102 WD2).
169
263
  #
@@ -198,18 +292,19 @@ module Rigor
198
292
  # Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
199
293
  # the innermost cresting scope (#354), then the bare name. When `rooted: true`, lexical nesting and
200
294
  # ancestors are skipped, answering the top-level declaration directly (#625).
201
- def resolve(as_written, nesting, rooted: false)
295
+ # `exclude` names a declaration the answer must not be (see {#resolve_ref}).
296
+ def resolve(as_written, nesting, rooted: false, exclude: nil, local: nil)
202
297
  if rooted
203
- return as_written if @by_fqn.key?(as_written)
298
+ return as_written if declared?(as_written, exclude, local)
204
299
 
205
300
  idx = as_written.rindex("::")
206
- return idx ? resolve(as_written[0, idx], nesting, rooted: true) : nil
301
+ return idx ? resolve(as_written[0, idx], nesting, rooted: true, exclude: exclude, local: local) : nil
207
302
  end
208
303
 
209
304
  walker = nesting.dup
210
305
  until walker.empty?
211
306
  candidate = (walker + [as_written]).join("::")
212
- return candidate if @by_fqn.key?(candidate)
307
+ return candidate if declared?(candidate, exclude, local)
213
308
 
214
309
  walker.pop
215
310
  end
@@ -217,11 +312,11 @@ module Rigor
217
312
  unless nesting.empty?
218
313
  ancestor_scopes(nesting.join("::")).each do |ancestor|
219
314
  candidate = "#{ancestor}::#{as_written}"
220
- return candidate if @by_fqn.key?(candidate)
315
+ return candidate if declared?(candidate, exclude, local)
221
316
  end
222
317
  end
223
318
 
224
- return as_written if @by_fqn.key?(as_written)
319
+ return as_written if declared?(as_written, exclude, local)
225
320
 
226
321
  # `Scope::DiscoveryIndex::EMPTY` names a constant INSIDE a class, and reading it is a use of that
227
322
  # class — but the leaf is not itself a declaration, so the reference would resolve to nothing and
@@ -229,7 +324,11 @@ module Rigor
229
324
  # on the first run of this report against Rigor's own `lib`). Peel the trailing segment and retry:
230
325
  # a reference to a member is a reference to its owner.
231
326
  idx = as_written.rindex("::")
232
- idx ? resolve(as_written[0, idx], nesting) : nil
327
+ idx ? resolve(as_written[0, idx], nesting, exclude: exclude, local: local) : nil
328
+ end
329
+
330
+ def declared?(fqn, exclude, local)
331
+ fqn != exclude && (@by_fqn.key?(fqn) || local&.include?(fqn))
233
332
  end
234
333
 
235
334
  # Breadth-first over superclass + included modules, mixins first, terminating on a cycle. As-written
@@ -260,7 +359,8 @@ module Rigor
260
359
  segments = subclass_fqn.split("::")
261
360
  (segments.length - 1).downto(0) do |i|
262
361
  candidate = (segments[0, i] + [raw]).join("::")
263
- return candidate if @by_fqn.key?(candidate)
362
+ # `class Api::User < User` cannot inherit from itself; the name was read before it existed.
363
+ return candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
264
364
  end
265
365
  nil
266
366
  end