rigortype 0.4.0 → 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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  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/08-understanding-errors.md +3 -2
  25. data/docs/handbook/11-sig-gen.md +6 -0
  26. data/docs/handbook/appendix-liskov.md +5 -0
  27. data/docs/install.md +87 -15
  28. data/docs/manual/02-cli-reference.md +2 -2
  29. data/docs/manual/03-configuration.md +6 -1
  30. data/docs/manual/04-diagnostics.md +2 -1
  31. data/docs/manual/06-baseline.md +2 -0
  32. data/docs/manual/07-plugins.md +1 -1
  33. data/docs/manual/08-skills.md +4 -3
  34. data/docs/manual/12-caching.md +43 -4
  35. data/docs/manual/14-rails-quickstart.md +19 -11
  36. data/docs/manual/18-removing-dead-code.md +32 -2
  37. data/docs/manual/plugins/README.md +6 -1
  38. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  39. data/docs/manual/plugins/rigor-alba.md +60 -0
  40. data/docs/manual/plugins/rigor-minitest.md +22 -4
  41. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  42. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  43. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  44. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  45. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +184 -61
  47. data/lib/rigor/analysis/check_rules.rb +407 -144
  48. data/lib/rigor/analysis/dependency_recorder.rb +215 -18
  49. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  50. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  51. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  52. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  53. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  54. data/lib/rigor/analysis/incremental.rb +29 -9
  55. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +291 -0
  56. data/lib/rigor/analysis/incremental_run_slot.rb +173 -0
  57. data/lib/rigor/analysis/incremental_session.rb +287 -50
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  59. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  60. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +43 -9
  64. data/lib/rigor/analysis/run_stats.rb +2 -2
  65. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +88 -6
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +52 -13
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  68. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  69. data/lib/rigor/analysis/runner.rb +371 -49
  70. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  71. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  72. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  73. data/lib/rigor/analysis/template_units.rb +15 -5
  74. data/lib/rigor/analysis/worker_session.rb +11 -7
  75. data/lib/rigor/cache/descriptor.rb +40 -1
  76. data/lib/rigor/cache/file_digest.rb +28 -0
  77. data/lib/rigor/cache/incremental_snapshot.rb +92 -8
  78. data/lib/rigor/cache/store.rb +63 -3
  79. data/lib/rigor/cache/value_digest.rb +325 -0
  80. data/lib/rigor/cli/annotate_command.rb +5 -0
  81. data/lib/rigor/cli/baseline_command.rb +7 -18
  82. data/lib/rigor/cli/check_command.rb +85 -14
  83. data/lib/rigor/cli/doctor_command.rb +1 -1
  84. data/lib/rigor/cli/effects_report.rb +2 -2
  85. data/lib/rigor/cli/trace_command.rb +5 -0
  86. data/lib/rigor/cli/type_of_command.rb +5 -3
  87. data/lib/rigor/cli/unused_command.rb +16 -10
  88. data/lib/rigor/cli.rb +11 -3
  89. data/lib/rigor/config_audit.rb +3 -2
  90. data/lib/rigor/config_path.rb +29 -0
  91. data/lib/rigor/configuration/severity_profile.rb +11 -0
  92. data/lib/rigor/configuration.rb +52 -7
  93. data/lib/rigor/effects/plugin_facts.rb +6 -6
  94. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  95. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  96. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  97. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  98. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  99. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  100. data/lib/rigor/environment/rbs_loader.rb +284 -15
  101. data/lib/rigor/environment/required_features.rb +206 -0
  102. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  103. data/lib/rigor/environment.rb +34 -73
  104. data/lib/rigor/inference/acceptance.rb +28 -3
  105. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  106. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  107. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  108. data/lib/rigor/inference/captured_locals.rb +7 -1
  109. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  110. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  111. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  112. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  113. data/lib/rigor/inference/declaration_walk.rb +7 -0
  114. data/lib/rigor/inference/def_node_resolver.rb +26 -3
  115. data/lib/rigor/inference/definer_resolution.rb +266 -0
  116. data/lib/rigor/inference/expression_typer.rb +351 -61
  117. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  118. data/lib/rigor/inference/global_write_census.rb +7 -4
  119. data/lib/rigor/inference/guard_rebinding.rb +54 -13
  120. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  121. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  122. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  123. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  124. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  125. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  126. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  127. data/lib/rigor/inference/macro_block_self_type.rb +73 -14
  128. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  129. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  130. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  131. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  132. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  133. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  134. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  135. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  136. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +305 -54
  137. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  138. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  139. data/lib/rigor/inference/module_function_state.rb +214 -0
  140. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  141. data/lib/rigor/inference/narrowing.rb +166 -22
  142. data/lib/rigor/inference/object_mixins.rb +180 -0
  143. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  144. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  145. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  146. data/lib/rigor/inference/refine_census.rb +239 -0
  147. data/lib/rigor/inference/refine_self.rb +317 -0
  148. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  149. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  150. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  151. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  152. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  153. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  154. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  155. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +143 -0
  156. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  157. data/lib/rigor/inference/scope_indexer.rb +2129 -354
  158. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  159. data/lib/rigor/inference/statement_evaluator.rb +149 -32
  160. data/lib/rigor/inference/string_mutation.rb +9 -3
  161. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  162. data/lib/rigor/inference/version_guard.rb +19 -10
  163. data/lib/rigor/language_server/project_context.rb +31 -0
  164. data/lib/rigor/language_server/server.rb +18 -0
  165. data/lib/rigor/plugin/base.rb +28 -8
  166. data/lib/rigor/plugin/inflector.rb +1 -1
  167. data/lib/rigor/plugin/io_boundary.rb +124 -7
  168. data/lib/rigor/plugin/isolation.rb +1 -1
  169. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  170. data/lib/rigor/plugin/trust_policy.rb +3 -3
  171. data/lib/rigor/project_environment.rb +29 -6
  172. data/lib/rigor/protection/discovery_seed.rb +18 -9
  173. data/lib/rigor/protection/mutation_cache.rb +4 -4
  174. data/lib/rigor/protection/mutator.rb +1 -1
  175. data/lib/rigor/reflection/constant_ancestors.rb +211 -46
  176. data/lib/rigor/reflection/constant_path.rb +33 -7
  177. data/lib/rigor/reflection.rb +41 -10
  178. data/lib/rigor/scope/discovery_index.rb +258 -3
  179. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  180. data/lib/rigor/scope/resolution_chain.rb +1032 -0
  181. data/lib/rigor/scope.rb +480 -328
  182. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  183. data/lib/rigor/sig_gen/classification.rb +4 -1
  184. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  185. data/lib/rigor/sig_gen/generator.rb +77 -21
  186. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  187. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  188. data/lib/rigor/source/alias_names.rb +52 -0
  189. data/lib/rigor/source/literals.rb +3 -2
  190. data/lib/rigor/source/symbol_name.rb +20 -0
  191. data/lib/rigor/type/combinator.rb +59 -3
  192. data/lib/rigor/type/float_range.rb +4 -1
  193. data/lib/rigor/type/nominal.rb +6 -1
  194. data/lib/rigor/version.rb +1 -1
  195. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  196. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  197. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  198. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  199. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  200. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  201. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  202. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  203. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  204. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  207. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  208. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  209. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  210. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  211. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  212. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  213. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  214. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  215. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  216. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  217. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +13 -2
  218. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  219. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  220. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  221. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  222. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  223. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  224. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  225. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  226. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  227. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  228. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  229. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  230. data/sig/rigor/environment.rbs +2 -0
  231. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  232. data/sig/rigor/scope.rbs +69 -4
  233. data/sig/rigor/source.rbs +4 -0
  234. data/sig/rigor.rbs +3 -1
  235. data/skills/rigor-project-init/SKILL.md +37 -11
  236. data/skills/rigor-project-init/references/02-configure.md +7 -2
  237. metadata +90 -1
@@ -22,12 +22,52 @@ module Rigor
22
22
  # It is a STACK because captures nest (a memoised callee body evaluating another memo-miss callee): an
23
23
  # outer capture must also see the inner window's reads, or its read-set would be non-transitive and a
24
24
  # later replay would drop the nested callee's edges.
25
+ #
26
+ # Every event reaches every capture open at the time, so an inner capture's sets are always a subset of
27
+ # each outer one's: the inner capture was pushed after the outer, and everything added since reached both.
28
+ # {withhold} swaps in a fresh stack and restores this one untouched, so it keeps the property too.
29
+ # {tee_read} and {tee_missing} rely on it to stop at the first capture that already holds an event.
25
30
  CAPTURE_KEY = :__rigor_dependency_capture_stack__
26
31
  private_constant :CAPTURE_KEY
27
32
 
33
+ # The read sets an {Accumulator} or {Capture} already holds in full, by identity: replayed into it, or
34
+ # captured by a window it saw every event of ({capture}). Both only ever grow and a {ReadSet} is frozen,
35
+ # so replaying such a set into the same target again adds nothing. Recording replays the same stored
36
+ # read set many times over: on Rigor's own `lib`, 71% of the replays into a consumer repeated one
37
+ # already made into it, carrying 65% of the replayed events.
38
+ module ReplayMemory
39
+ def holds?(read_set)
40
+ !@held.nil? && @held.key?(read_set)
41
+ end
42
+
43
+ # Called only once every event of `read_set` is in, so a replay that raises part-way leaves the
44
+ # target unmarked and the next replay adds the rest.
45
+ def hold!(read_set)
46
+ (@held ||= {}.compare_by_identity)[read_set] = true
47
+ end
48
+ end
49
+
50
+ # #1590 — the resolution-chain filings ({file_chain_once}) an {Accumulator} or {Capture} already holds in
51
+ # full: per chain (by identity; the table holds the chain, so its identity cannot be reused while the target
52
+ # lives), the starts filed from it and the discovery index each was filed against. A filing is a pure
53
+ # function of those three, so filing it again into the same target adds nothing.
54
+ module ChainMemory
55
+ def chain_filed?(chain, start, discovery)
56
+ table = @chains&.[](chain)
57
+ !table.nil? && table.key?(start) && table[start].equal?(discovery)
58
+ end
59
+
60
+ def chain_filed!(chain, start, discovery)
61
+ ((@chains ||= {}.compare_by_identity)[chain] ||= {})[start] = discovery
62
+ end
63
+ end
64
+
28
65
  # Mutable per-consumer accumulator. Frozen into a {Record} snapshot when `record_for` returns.
29
66
  class Accumulator
30
- attr_reader :consumer, :sources, :missing, :symbol_sources, :ancestry_sources
67
+ include ReplayMemory
68
+ include ChainMemory
69
+
70
+ attr_reader :consumer, :sources, :missing, :symbol_sources, :ancestry_sources, :suspensions
31
71
 
32
72
  def initialize(consumer)
33
73
  @consumer = consumer
@@ -39,6 +79,15 @@ module Rigor
39
79
  # file-granularity by nature (a superclass edge touches the whole class).
40
80
  @symbol_sources = Hash.new { |h, k| h[k] = Set.new }
41
81
  @ancestry_sources = Set.new
82
+ @held = nil
83
+ @chains = nil
84
+ @suspensions = 0
85
+ end
86
+
87
+ # Counts the {record_for} windows of another consumer opened while this one was recording, which is
88
+ # when a capture window stops seeing only this consumer's events.
89
+ def suspend!
90
+ @suspensions += 1
42
91
  end
43
92
 
44
93
  def snapshot
@@ -65,16 +114,28 @@ module Rigor
65
114
  # self-read filter is applied at {replay} time, mirroring {read_site}.
66
115
  ReadSet = Data.define(:reads, :missing)
67
116
 
117
+ # What every window that saw nothing closes into: most windows {RbsDispatch}'s recording-mode memos
118
+ # open see nothing, and they need neither a copy of two empty Sets nor a place in a target's
119
+ # {ReplayMemory}.
120
+ EMPTY_READ_SET = Ractor.make_shareable(ReadSet.new(reads: Set.new, missing: Set.new))
121
+
68
122
  # Mutable capture accumulator; snapshot into a frozen {ReadSet} when the capture window closes.
69
123
  class Capture
124
+ include ReplayMemory
125
+ include ChainMemory
126
+
70
127
  attr_reader :reads, :missing
71
128
 
72
129
  def initialize
73
130
  @reads = Set.new
74
131
  @missing = Set.new
132
+ @held = nil
133
+ @chains = nil
75
134
  end
76
135
 
77
136
  def snapshot
137
+ return EMPTY_READ_SET if reads.empty? && missing.empty?
138
+
78
139
  ReadSet.new(reads: reads.dup.freeze, missing: missing.dup.freeze)
79
140
  end
80
141
  end
@@ -94,6 +155,7 @@ module Rigor
94
155
  # the previous recorder on exit.
95
156
  def record_for(consumer)
96
157
  previous = Thread.current[KEY]
158
+ previous&.suspend!
97
159
  accumulator = Accumulator.new(consumer.to_s)
98
160
  Thread.current[KEY] = accumulator
99
161
  @mutex.synchronize { @active_count += 1 }
@@ -109,6 +171,13 @@ module Rigor
109
171
  @active_count.positive?
110
172
  end
111
173
 
174
+ # Whether THIS thread's reads reach an accumulator: inside {record_for}, including a {withhold}
175
+ # window. {active?} answers for the process and is the cheap gate; this is the exact one, for a caller
176
+ # that must know whether an empty {capture} means "nothing to record" or "nothing was watching".
177
+ def recording?
178
+ !Thread.current[KEY].nil?
179
+ end
180
+
112
181
  # ADR-84 WD2 — runs the block under an observe-and-forward capture window and returns
113
182
  # `[block_result, read_set]`. Reads recorded during the window reach the current consumer's accumulator
114
183
  # unchanged (forwarding) and are additionally collected into the returned frozen {ReadSet} (observing),
@@ -116,6 +185,8 @@ module Rigor
116
185
  # {active?}; opening a capture with no accumulator active would collect reads no run is recording.
117
186
  def capture
118
187
  captures = (Thread.current[CAPTURE_KEY] ||= [])
188
+ accumulator = Thread.current[KEY]
189
+ suspensions = accumulator&.suspensions
119
190
  capture = Capture.new
120
191
  captures.push(capture)
121
192
  begin
@@ -123,9 +194,28 @@ module Rigor
123
194
  ensure
124
195
  captures.pop
125
196
  end
126
- [result, capture.snapshot]
197
+ read_set = capture.snapshot
198
+ note_captured(read_set, captures, accumulator, suspensions)
199
+ [result, read_set]
127
200
  end
128
201
 
202
+ # Every event a window saw also reached the window outside it, and was filed for the consumer that was
203
+ # recording at the time, except the consumer's own self-reads, which a replay into that consumer drops
204
+ # anyway. So the enclosing window holds the closed window's read set in full, and so does the consumer's
205
+ # accumulator when no other consumer recorded in between. Noting that lets a later replay of the set
206
+ # there skip a pass that could add nothing: on Rigor's own `lib`, two thirds of the first replays into a
207
+ # consumer were of a set captured while that consumer was recording.
208
+ def note_captured(read_set, captures, accumulator, suspensions)
209
+ return if read_set.equal?(EMPTY_READ_SET)
210
+
211
+ captures.last&.hold!(read_set)
212
+ return unless accumulator && Thread.current[KEY].equal?(accumulator)
213
+ return unless accumulator.suspensions == suspensions
214
+
215
+ accumulator.hold!(read_set)
216
+ end
217
+ private_class_method :note_captured
218
+
129
219
  # Issue #992 — runs the block with its reads DETACHED from the current consumer (and from any enclosing
130
220
  # capture) and returns `[block_result, read_set]`, so a caller that learns only afterwards whether its
131
221
  # answer depended on those reads can {replay} them or drop them. `call.wrong-arity` reads a class's whole
@@ -154,22 +244,75 @@ module Rigor
154
244
  # capture so an enclosing capture window stays transitive when a memo hit substitutes for a body walk.
155
245
  def replay(read_set)
156
246
  accumulator = Thread.current[KEY]
157
- return if accumulator.nil? || read_set.nil?
247
+ return if accumulator.nil? || read_set.nil? || read_set.equal?(EMPTY_READ_SET)
158
248
 
159
249
  captures = Thread.current[CAPTURE_KEY]
250
+ tee_read_set(captures, read_set) if captures && !captures.empty?
251
+ # Each set keeps its own insertion order whichever target is filled first, and a skipped replay is one
252
+ # that could only have re-added what the target already holds.
253
+ return if accumulator.holds?(read_set)
254
+
255
+ consumer = accumulator.consumer
160
256
  read_set.reads.each do |pair|
161
- captures&.each { |c| c.reads.add(pair) }
162
257
  path, symbol = pair
163
- next if path == accumulator.consumer
164
-
165
- accumulate_read(accumulator, path, symbol)
258
+ accumulate_read(accumulator, path, symbol) unless path == consumer
166
259
  end
167
- read_set.missing.each do |entry|
168
- captures&.each { |c| c.missing.add(entry) }
169
- accumulator.missing << entry
260
+ missing = accumulator.missing
261
+ read_set.missing.each { |entry| missing << entry }
262
+ accumulator.hold!(read_set)
263
+ end
264
+
265
+ # #1590 — runs the block, which files a resolution chain's edges from entry `start` (nil: the root and the
266
+ # whole chain) against `discovery`, unless the current consumer's accumulator AND the innermost open capture
267
+ # already hold that filing; the block files the same edges every time, so skipping it there changes nothing.
268
+ # Every event reaches every capture open at the time (see CAPTURE_KEY), so the innermost capture holding the
269
+ # filing means every open capture does. A filing the accumulator holds but a capture opened since does not is
270
+ # filed again, so that capture's read set, replayed elsewhere, still carries it; and {withhold} starts a fresh
271
+ # accumulator and capture, which hold nothing. The marks are made only once the block has run, as {hold!}'s are.
272
+ def file_chain_once(chain, start, discovery)
273
+ accumulator = Thread.current[KEY]
274
+ return if accumulator.nil?
275
+
276
+ captures = Thread.current[CAPTURE_KEY]
277
+ open = captures && !captures.empty?
278
+ if accumulator.chain_filed?(chain, start, discovery) &&
279
+ (!open || captures.last.chain_filed?(chain, start, discovery))
280
+ return
170
281
  end
282
+
283
+ yield
284
+ accumulator.chain_filed!(chain, start, discovery)
285
+ captures.each { |capture| capture.chain_filed!(chain, start, discovery) } if open
171
286
  end
172
287
 
288
+ # {replay}'s share for the open captures. The innermost capture having seen `read_set` already means every
289
+ # open capture holds all of it (see CAPTURE_KEY).
290
+ def tee_read_set(captures, read_set)
291
+ innermost = captures.last
292
+ return if innermost.holds?(read_set)
293
+
294
+ read_set.reads.each { |pair| tee_read(captures, pair) }
295
+ read_set.missing.each { |entry| tee_missing(captures, entry) }
296
+ innermost.hold!(read_set)
297
+ end
298
+ private_class_method :tee_read_set
299
+
300
+ # Adds a read pair to the open captures, innermost first, and stops at the first capture that already holds
301
+ # it: every capture outside that one holds it too (see CAPTURE_KEY). The capture sets end up exactly as
302
+ # adding it to every capture would leave them, in the same insertion order.
303
+ def tee_read(captures, pair)
304
+ index = captures.size - 1
305
+ index -= 1 while index >= 0 && captures[index].reads.add?(pair)
306
+ end
307
+ private_class_method :tee_read
308
+
309
+ # {tee_read} for a name-keyed `missing` entry.
310
+ def tee_missing(captures, entry)
311
+ index = captures.size - 1
312
+ index -= 1 while index >= 0 && captures[index].missing.add?(entry)
313
+ end
314
+ private_class_method :tee_missing
315
+
173
316
  # Records that the current consumer read a declaration / body whose definition site is `path_line` (a
174
317
  # `"path:line"` String, or nil). When `symbol` is given (a `"ClassName#method"` String), the read is a
175
318
  # method-call edge and is recorded at symbol granularity in `symbol_sources` in addition to the coarse
@@ -181,16 +324,26 @@ module Rigor
181
324
  accumulator = Thread.current[KEY]
182
325
  return if accumulator.nil? || path_line.nil?
183
326
 
184
- path = path_line.split(":", 2).first
327
+ path = site_path(path_line)
185
328
  return unless path
186
329
 
187
330
  captures = Thread.current[CAPTURE_KEY]
188
- captures&.each { |c| c.reads.add([path, symbol].freeze) }
331
+ tee_read(captures, [path, symbol].freeze) if captures && !captures.empty?
189
332
  return if path == accumulator.consumer
190
333
 
191
334
  accumulate_read(accumulator, path, symbol)
192
335
  end
193
336
 
337
+ # The path part of a `"path:line"` site: what `path_line.split(":", 2).first` answers, without the Array
338
+ # and the discarded line String.
339
+ def site_path(path_line)
340
+ colon = path_line.byteindex(":")
341
+ return path_line.byteslice(0, colon) if colon
342
+
343
+ path_line.empty? ? nil : path_line.dup
344
+ end
345
+ private_class_method :site_path
346
+
194
347
  # Records a NAME-KEYED cross-file dependency: this consumer's answer depends on what the project makes
195
348
  # of `name` under `kind`, so a recheck must re-analyze it when that changes. The recorded key is
196
349
  # inverted into `negative_dependents` and matched against the producer for its kind.
@@ -206,10 +359,53 @@ module Rigor
206
359
  return if accumulator.nil?
207
360
 
208
361
  entry = "#{kind}:#{name}"
209
- Thread.current[CAPTURE_KEY]&.each { |c| c.missing.add(entry) }
362
+ captures = Thread.current[CAPTURE_KEY]
363
+ tee_missing(captures, entry) if captures && !captures.empty?
210
364
  accumulator.missing.add(entry)
211
365
  end
212
366
 
367
+ # {read_name} keyed on the LAST SEGMENT of a constant path, `name.delete_prefix("::").split("::").last`,
368
+ # which is the key the `class:` and `constant:` name edges use. The segment is found by scanning for the
369
+ # last separator rather than by building every segment: on Rigor's own `lib` that split cost about seven
370
+ # objects a call over half a million calls. A name the scan could read differently from `split` (one
371
+ # that is not ASCII, or carries a run of colons or a trailing one) takes `split`'s answer.
372
+ def read_last_segment(kind, name)
373
+ return if Thread.current[KEY].nil?
374
+
375
+ segment = last_segment(name)
376
+ read_name(kind, segment) if segment
377
+ end
378
+
379
+ def last_segment(name)
380
+ return name.delete_prefix("::").split("::").last unless plain_constant_path?(name)
381
+
382
+ start = name.start_with?("::") ? 2 : 0
383
+ separator = name.rindex("::")
384
+ cut = separator && separator >= start ? separator + 2 : start
385
+ cut == name.bytesize ? nil : name.byteslice(cut, name.bytesize - cut)
386
+ end
387
+ private_class_method :last_segment
388
+
389
+ def plain_constant_path?(name)
390
+ name.ascii_only? && !name.end_with?(":") && !name.include?(":::")
391
+ end
392
+ private_class_method :plain_constant_path?
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
+
213
409
  # The miss-only spelling of {read_name}: a cross-file lookup of `name` (kind `:method` / `:class` /
214
410
  # `:toplevel` / …) that resolved to nothing. Kept as the name the internal spec's negative-edge
215
411
  # contract uses.
@@ -217,13 +413,14 @@ module Rigor
217
413
  read_name(kind, name)
218
414
  end
219
415
 
220
- # The positive-edge aggregation shared by {read_site} and {replay}.
416
+ # The positive-edge aggregation shared by {read_site} and {replay}. `sources` is the union of the other
417
+ # two tables' paths, and this is the only writer of all three, so a read the finer table already holds is
418
+ # already in `sources` and costs one Set probe instead of two.
221
419
  def accumulate_read(accumulator, path, symbol)
222
- accumulator.sources << path
223
420
  if symbol
224
- accumulator.symbol_sources[path] << symbol
225
- else
226
- accumulator.ancestry_sources << path
421
+ accumulator.sources << path if accumulator.symbol_sources[path].add?(symbol)
422
+ elsif accumulator.ancestry_sources.add?(path)
423
+ accumulator.sources << path
227
424
  end
228
425
  end
229
426
  private_class_method :accumulate_read
@@ -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