rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -10,6 +10,8 @@ require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
11
  require_relative "../inference/fallback_tracer"
12
12
  require_relative "../inference/scope_indexer"
13
+ require_relative "../inference/precision_scanner"
14
+ require_relative "../source/node_children"
13
15
  require_relative "type_of_renderer"
14
16
  require_relative "command"
15
17
  require_relative "options"
@@ -26,9 +28,23 @@ module Rigor
26
28
  # type-of UX (extra flags, watch mode, streaming output) without bloating the CLI shell. Output formatting is
27
29
  # delegated to {TypeOfRenderer}.
28
30
  class TypeOfCommand < Command
29
- USAGE = "Usage: rigor type-of [options] FILE:LINE:COL"
31
+ USAGE = "Usage: rigor type-of [options] FILE:LINE[:COL] [FILE:LINE[:COL] ...]"
30
32
 
31
- Result = Data.define(:file, :line, :column, :node, :type, :tracer)
33
+ Result = Data.define(:file, :line, :column, :node, :type, :tracer, :enumeration) do
34
+ def initialize(enumeration: nil, **rest)
35
+ super
36
+ end
37
+ end
38
+
39
+ LineEnumeration = Data.define(:total, :shown)
40
+
41
+ # One requested position. A nil `column` means "the first 40 expressions starting on this line" — the form that
42
+ # exists because hand-computing a column is the single most error-prone part of using this command.
43
+ Target = Data.define(:file, :line, :column)
44
+
45
+ # Cap on the expressions one bare `FILE:LINE` prints, so a long line cannot bury the answer.
46
+ LINE_ENUMERATION_CAP = 40
47
+ private_constant :LINE_ENUMERATION_CAP
32
48
 
33
49
  # @return [Integer] CLI exit status.
34
50
  def run
@@ -36,10 +52,10 @@ module Rigor
36
52
  buffer = Options.resolve_buffer_binding(options, err: @err)
37
53
  return CLI::EXIT_USAGE if buffer == :usage_error
38
54
 
39
- target = parse_position_argument(@argv)
40
- return CLI::EXIT_USAGE if target.nil?
55
+ targets = parse_position_arguments(@argv)
56
+ return CLI::EXIT_USAGE if targets.nil?
41
57
 
42
- execute(target: target, options: options, buffer: buffer)
58
+ execute(targets: targets, options: options, buffer: buffer)
43
59
  end
44
60
 
45
61
  private
@@ -59,38 +75,123 @@ module Rigor
59
75
  options
60
76
  end
61
77
 
62
- def execute(target:, options:, buffer: nil)
63
- file, line, column = target
64
- # Under editor mode the logical `file` may not exist on disk (user editing a new file); the runtime check is
65
- # only that the BUFFER is readable, which `resolve_buffer_binding` has already enforced. For non-editor mode
66
- # `file` must exist.
78
+ # One process, N positions. Every invocation used to rebuild the plugin-aware environment and reparse the
79
+ # file for a single answer, so investigating a chain of expressions cost one process each — the dominant
80
+ # cost of using this command in anger. The environment is now built once for the whole request and each
81
+ # file is parsed and scope-indexed once, however many positions land in it.
82
+ def execute(targets:, options:, buffer: nil)
83
+ configuration = Configuration.load(options.fetch(:config))
84
+ environment = project_environment(targets.map(&:file).uniq, configuration)
85
+ base_scope = Scope.empty(environment: environment)
86
+
87
+ results_by_target = Array.new(targets.length)
88
+ targets.each_with_index.group_by { |target, _index| target.file }.each do |file, indexed_targets|
89
+ file_results = resolve_file(file, indexed_targets, configuration, base_scope, options, buffer)
90
+ return file_results if file_results.is_a?(Integer)
91
+
92
+ file_results.each { |index, resolved| results_by_target[index] = resolved }
93
+ end
94
+ results = results_by_target.compact.flatten(1)
95
+ return 1 if results.empty?
96
+
97
+ TypeOfRenderer.new(out: @out).render(results, format: options.fetch(:format))
98
+ 0
99
+ end
100
+
101
+ # Every result for one file: parsed and indexed once, then each requested position resolved against that
102
+ # index. Returns an Integer exit status instead when the file itself cannot be probed.
103
+ def resolve_file(file, indexed_targets, configuration, base_scope, options, buffer)
104
+ # Under editor mode the logical `file` may not exist on disk (user editing a new file); the runtime check
105
+ # is only that the BUFFER is readable, which `resolve_buffer_binding` has already enforced.
67
106
  physical = buffer ? buffer.resolve(file) : file
68
107
  return 1 unless file_exists?(buffer ? physical : file)
69
108
 
70
- configuration = Configuration.load(options.fetch(:config))
71
109
  source = File.read(physical)
72
110
  parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
73
111
  return 1 if parse_errors?(parse_result, file)
74
112
 
75
- node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
76
- return CLI::EXIT_USAGE if node == :out_of_range
77
- return 1 if node.nil?
113
+ # Built with no tracer attached it would otherwise double-record fallback events with the per-node
114
+ # `type_of` calls below.
115
+ scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
116
+ locator = Source::NodeLocator.new(source: source, root: parse_result.value)
117
+ lines = indexed_targets.filter_map { |target, _index| target.line if target.column.nil? }
118
+ line_nodes, line_totals = collect_line_nodes(parse_result.value, lines)
78
119
 
79
- tracer = options[:trace] ? Inference::FallbackTracer.new : nil
80
- base_scope = Scope.empty(environment: project_environment(file, configuration))
120
+ resolve_targets(file: file, indexed_targets: indexed_targets, locator: locator, line_nodes: line_nodes,
121
+ line_totals: line_totals, scope_index: scope_index, options: options)
122
+ end
81
123
 
82
- # Build a per-node scope index so locals bound earlier in the file flow into the scope used to type the queried
83
- # node. We build the index with no tracer attached (it would otherwise double-record fallback events with the
84
- # second-pass type_of call below), then look up the scope visible at the queried node and run the actual probe
85
- # under it.
86
- scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
87
- node_scope = scope_index[node]
124
+ def resolve_targets(file:, indexed_targets:, locator:, line_nodes:, line_totals:, scope_index:, options:)
125
+ indexed_targets.map do |target, index|
126
+ if target.column.nil?
127
+ resolved = enumerate_line(file, target.line, line_nodes.fetch(target.line),
128
+ line_totals.fetch(target.line), scope_index, options)
129
+ next [index, resolved]
130
+ end
88
131
 
89
- type = node_scope.type_of(node, tracer: tracer)
90
- result = Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer)
132
+ node = locate_node(locator: locator, file: file, line: target.line, column: target.column)
133
+ return CLI::EXIT_USAGE if node == :out_of_range
134
+ next [index, []] if node.nil?
91
135
 
92
- TypeOfRenderer.new(out: @out).render(result, format: options.fetch(:format))
93
- 0
136
+ [index, [type_result(file, target.line, target.column, node, scope_index, options)]]
137
+ end
138
+ end
139
+
140
+ # Collect every requested line in one full syntax walk. Unlike `Source::NodeWalker`, this deliberately
141
+ # descends into `defined?`: the probe inventories source expressions, including operands Ruby inspects but
142
+ # does not evaluate. Candidate storage stays bounded while retaining the first 40 nodes in display order.
143
+ def collect_line_nodes(root, lines)
144
+ return [{}, {}] if lines.empty?
145
+
146
+ candidates = lines.uniq.to_h { |line| [line, []] }
147
+ totals = candidates.to_h { |line, _nodes| [line, 0] }
148
+ walk_syntax(root) do |node|
149
+ line = node.location.start_line
150
+ nodes = candidates[line]
151
+ next if nodes.nil?
152
+ next if Inference::PrecisionScanner::NON_EXPRESSION_NODE_TYPES.include?(node.class.name)
153
+
154
+ totals[line] += 1
155
+ nodes << node
156
+ trim_line_nodes(nodes) if nodes.length > LINE_ENUMERATION_CAP * 2
157
+ end
158
+ candidates.each_value { |nodes| trim_line_nodes(nodes) }
159
+ [candidates, totals]
160
+ end
161
+
162
+ def walk_syntax(node, &block)
163
+ return unless node.is_a?(Prism::Node)
164
+
165
+ block.call(node)
166
+ node.rigor_each_child { |child| walk_syntax(child, &block) }
167
+ end
168
+
169
+ def trim_line_nodes(nodes)
170
+ nodes.sort_by! { |node| [node.location.start_column, -node.location.length] }
171
+ nodes.slice!(LINE_ENUMERATION_CAP, nodes.length)
172
+ end
173
+
174
+ # Up to 40 expressions STARTING on `line`, outermost first at each 1-based column. This form removes
175
+ # the column arithmetic: `rigor type-of file.rb:42` answers "what are the types on line 42" without the user
176
+ # having to guess which byte the receiver starts at.
177
+ def enumerate_line(file, line, nodes, total, scope_index, options)
178
+ if nodes.empty?
179
+ @err.puts("type-of: no expression found on #{file}:#{line}")
180
+ return []
181
+ end
182
+
183
+ enumeration = LineEnumeration.new(total: total, shown: nodes.length)
184
+ nodes.map do |node|
185
+ type_result(file, line, node.location.start_column + 1, node, scope_index, options,
186
+ enumeration: enumeration)
187
+ end
188
+ end
189
+
190
+ def type_result(file, line, column, node, scope_index, options, enumeration: nil)
191
+ tracer = options[:trace] ? Inference::FallbackTracer.new : nil
192
+ type = scope_index[node].type_of(node, tracer: tracer)
193
+ Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer,
194
+ enumeration: enumeration)
94
195
  end
95
196
 
96
197
  # Builds the plugin-aware environment relative to the probed file, so the reported type matches what `rigor
@@ -99,8 +200,8 @@ module Rigor
99
200
  # closes). The probed file is threaded as the synthesizer's `source_files:`. Project-RBS auto-detection
100
201
  # roots at CWD today; future work will walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
101
202
  # so probes against files outside the current process's CWD still see the right `sig/` tree.
102
- def project_environment(file, configuration)
103
- ProbeEnvironment.build(configuration: configuration, source_files: [file])
203
+ def project_environment(files, configuration)
204
+ ProbeEnvironment.build(configuration: configuration, source_files: files)
104
205
  end
105
206
 
106
207
  def file_exists?(file)
@@ -117,8 +218,8 @@ module Rigor
117
218
  true
118
219
  end
119
220
 
120
- def locate_node(source:, root:, file:, line:, column:)
121
- node = Source::NodeLocator.at_position(source: source, root: root, line: line, column: column)
221
+ def locate_node(locator:, file:, line:, column:)
222
+ node = locator.at_position(line: line, column: column)
122
223
  @err.puts("type-of: no expression found at #{file}:#{line}:#{column}") if node.nil?
123
224
  node
124
225
  rescue Source::NodeLocator::OutOfRangeError => e
@@ -126,19 +227,66 @@ module Rigor
126
227
  :out_of_range
127
228
  end
128
229
 
129
- def parse_position_argument(argv)
130
- case argv.size
131
- when 1
132
- parse_colon_form(argv[0])
133
- when 3
134
- decode_position(*argv)
135
- else
136
- @err.puts("type-of: expected FILE:LINE:COL or FILE LINE COL")
230
+ def parse_position_arguments(argv)
231
+ if argv.empty?
232
+ @err.puts("type-of: expected FILE:LINE[:COL] or FILE LINE COL")
137
233
  @err.puts(USAGE)
138
- nil
234
+ return nil
139
235
  end
236
+
237
+ colon = argv.map { |arg| try_colon_form(arg) }
238
+ return colon if colon.all?
239
+ return legacy_triple(argv) if argv.size == 3 && colon.none?
240
+ # A single unparseable argument keeps its old, specific diagnosis ("must be integers", "expected
241
+ # FILE:LINE:COL") rather than the generic multi-position complaint below.
242
+ return single_colon_form(argv[0]) if argv.size == 1
243
+
244
+ @err.puts("type-of: expected FILE:LINE[:COL] (repeatable) or FILE LINE COL, got #{argv.inspect}")
245
+ @err.puts(USAGE)
246
+ nil
247
+ end
248
+
249
+ # `FILE:LINE:COL` or `FILE:LINE`, or nil when the argument is not in either form. Silent: the caller
250
+ # decides whether a nil means "try the three-argument form" or "report a usage error", and a probe should
251
+ # not print a complaint about a shape it is about to accept under a different reading.
252
+ def try_colon_form(arg)
253
+ parts = arg.split(":")
254
+ return nil if parts.size < 2 || !integer_literal?(parts[-1])
255
+
256
+ # Three or more segments is the `FILE:LINE:COL` reading; if its LINE is not a number the argument is a
257
+ # typo, not a column-less position, and must reach the specific "must be integers" diagnosis.
258
+ if parts.size >= 3
259
+ return nil unless integer_literal?(parts[-2])
260
+
261
+ Target.new(file: parts[0..-3].join(":"), line: Integer(parts[-2], 10), column: Integer(parts[-1], 10))
262
+ else
263
+ Target.new(file: parts[0], line: Integer(parts[-1], 10), column: nil)
264
+ end
265
+ end
266
+
267
+ def integer_literal?(value)
268
+ value.match?(/\A\d+\z/)
269
+ end
270
+
271
+ # The pre-batch single-argument path, kept for its error messages. Returns nil after printing one.
272
+ def single_colon_form(arg)
273
+ position = parse_colon_form(arg)
274
+ return nil if position.nil?
275
+
276
+ file, line, column = position
277
+ [Target.new(file: file, line: line, column: column)]
278
+ end
279
+
280
+ def legacy_triple(argv)
281
+ position = decode_position(*argv)
282
+ return nil if position.nil?
283
+
284
+ file, line, column = position
285
+ [Target.new(file: file, line: line, column: column)]
140
286
  end
141
287
 
288
+ # The strict `FILE:LINE:COL` reading, used only to produce the specific error for a single unparseable
289
+ # argument. The permissive reading (which also accepts `FILE:LINE`) is {try_colon_form}.
142
290
  def parse_colon_form(arg)
143
291
  parts = arg.split(":")
144
292
  if parts.size < 3
@@ -149,8 +297,7 @@ module Rigor
149
297
 
150
298
  column = parts.pop
151
299
  line = parts.pop
152
- file = parts.join(":")
153
- decode_position(file, line, column)
300
+ decode_position(parts.join(":"), line, column)
154
301
  end
155
302
 
156
303
  def decode_position(file, line, column)
@@ -20,7 +20,55 @@ module Rigor
20
20
 
21
21
  private
22
22
 
23
- def render_text(result)
23
+ # Takes the whole result list: one invocation can now answer several positions, and a bare `FILE:LINE`
24
+ # answers up to 40 expressions on that line. A single exact result renders byte-identically to before.
25
+ def render_text(results)
26
+ list = Array(results)
27
+ index = 0
28
+ first = true
29
+ while index < list.length
30
+ @out.puts("") unless first
31
+ result = list[index]
32
+ enumeration = result.enumeration
33
+ if enumeration
34
+ finish = index + 1
35
+ finish += 1 while finish < list.length && list[finish].enumeration.equal?(enumeration)
36
+ render_enumeration(list[index...finish], enumeration)
37
+ index = finish
38
+ else
39
+ render_one_text(result)
40
+ index += 1
41
+ end
42
+ first = false
43
+ end
44
+ end
45
+
46
+ # A bare `FILE:LINE` asks "what are the types on this line", and the answer is a table: one row per
47
+ # expression, outermost first at each column, so a method chain reads top to bottom.
48
+ def render_enumeration(results, enumeration)
49
+ @out.puts("#{results.first.file}:#{results.first.line}")
50
+ render_enumeration_rows(results)
51
+ if enumeration.total > results.length
52
+ omitted = enumeration.total - results.length
53
+ @out.puts(" ... #{omitted} additional expressions omitted (limit #{results.length})")
54
+ end
55
+ # After the table, never interleaved with it: a `--trace` fallback list between two rows would break the
56
+ # column alignment the whole form exists for.
57
+ results.each { |result| render_text_fallbacks(result) }
58
+ end
59
+
60
+ def render_enumeration_rows(results)
61
+ width = results.map { |result| node_label(result).length }.max
62
+ results.each do |result|
63
+ @out.puts(format(" %4d %-#{width}s %s", result.column, node_label(result), result.type.describe))
64
+ end
65
+ end
66
+
67
+ def node_label(result)
68
+ result.node.class.name.to_s.delete_prefix("Prism::")
69
+ end
70
+
71
+ def render_one_text(result)
24
72
  @out.puts("#{result.file}:#{result.line}:#{result.column}")
25
73
  @out.puts("node: #{result.node.class}")
26
74
  @out.puts("type: #{result.type.describe}")
@@ -40,7 +88,28 @@ module Rigor
40
88
  end
41
89
  end
42
90
 
43
- def render_json(result)
91
+ # A single result keeps the flat object it has always emitted, so existing consumers are untouched; a
92
+ # multi-position request wraps them in `results` rather than printing several documents to one stream.
93
+ def render_json(results)
94
+ list = Array(results)
95
+ payload = list.size == 1 ? result_to_h(list.first) : { results: list.map { |r| result_to_h(r) } }
96
+ enumerations = line_enumerations(list)
97
+ payload[:line_enumerations] = enumerations unless enumerations.empty?
98
+ @out.puts(JSON.pretty_generate(payload))
99
+ end
100
+
101
+ def line_enumerations(results)
102
+ seen = {}.compare_by_identity
103
+ results.filter_map do |result|
104
+ enumeration = result.enumeration
105
+ next if enumeration.nil? || seen.key?(enumeration)
106
+
107
+ seen[enumeration] = true
108
+ { file: result.file, line: result.line, shown: enumeration.shown, total: enumeration.total }
109
+ end
110
+ end
111
+
112
+ def result_to_h(result)
44
113
  payload = {
45
114
  file: result.file,
46
115
  line: result.line,
@@ -50,7 +119,7 @@ module Rigor
50
119
  erased: result.type.erase_to_rbs
51
120
  }
52
121
  payload[:fallbacks] = result.tracer.map { |event| fallback_to_h(event) } if result.tracer
53
- @out.puts(JSON.pretty_generate(payload))
122
+ payload
54
123
  end
55
124
 
56
125
  def format_fallback_text(event, file)
@@ -12,6 +12,7 @@ require_relative "type_scan_renderer"
12
12
  require_relative "type_scan_report"
13
13
  require_relative "command"
14
14
  require_relative "probe_environment"
15
+ require_relative "coverage_scan"
15
16
 
16
17
  module Rigor
17
18
  class CLI
@@ -70,7 +71,15 @@ module Rigor
70
71
 
71
72
  def scan_paths(paths, options)
72
73
  configuration = Configuration.load(options.fetch(:config))
73
- scope = Scope.empty(environment: project_environment(configuration, paths))
74
+ # Same seed set as `rigor coverage` and `coverage --protection` (#502). Without it a cross-file class
75
+ # constant reads `Dynamic` and is counted as an unrecognised node, so the scan reports the engine as
76
+ # blinder than it is — on Mastodon that alone was most of a 14.7% unrecognised rate.
77
+ scope = CoverageScan.discovery_seeded_scope(
78
+ files: paths,
79
+ configuration: configuration,
80
+ environment: project_environment(configuration, paths),
81
+ parameter_inference: configuration.parameter_inference
82
+ )
74
83
  scanner = Inference::CoverageScanner.new(scope: scope)
75
84
  accumulator = ScanAccumulator.new
76
85
  paths.each { |path| scan_one(path, scanner, accumulator, configuration) }
data/lib/rigor/cli.rb CHANGED
@@ -376,7 +376,7 @@ module Rigor
376
376
  check Analyze Ruby source files
377
377
  init Create a starter .rigor.yml
378
378
  annotate Print FILE with each line's last-expression type
379
- type-of Print the inferred type at FILE:LINE:COL
379
+ type-of Print inferred types at FILE:LINE[:COL] positions
380
380
  trace Replay how the engine typed FILE as a terminal animation
381
381
  type-scan Report Scope#type_of coverage across PATHs
382
382
  effects Report each method's effect labels, and the committed effect snapshot
@@ -144,10 +144,13 @@ module Rigor
144
144
  },
145
145
  "bundler" => {
146
146
  # Open item O4 — target-project Bundler awareness. When `bundle_path:` is set (or auto-detected),
147
- # Rigor walks `<bundle_path>/ruby/*/gems/*/sig/` and adds each gem-shipped sig directory to
148
- # `signature_paths:`. With O7's failure-memo in place, conflicts (a vendored sig already declares the
149
- # same constant) degrade gracefully to "no RBS env" with a single-line warning naming the offending
150
- # file, rather than hanging.
147
+ # Rigor walks `<bundle_path>/ruby/*/gems/*/sig/` (RubyGems-sourced gems) and
148
+ # `<bundle_path>/ruby/*/bundler/gems/*/sig/` (`git:`-sourced gems) and adds each gem-shipped sig
149
+ # directory to `signature_paths:`. A `path:`-sourced gem is not under either Bundler uses the
150
+ # `path:` directory in place rather than copying it into the bundle root — so add its `sig/` directly
151
+ # via `signature_paths:` instead. With O7's failure-memo in place, conflicts (a vendored sig already
152
+ # declares the same constant) degrade gracefully to "no RBS env" with a single-line warning naming
153
+ # the offending file, rather than hanging.
151
154
  #
152
155
  # `bundle_path:` (String, optional): explicit path to the bundler install root (e.g.,
153
156
  # "vendor/bundle" or an absolute path). Resolved relative to the project root (`paths:`'s base) when
@@ -326,7 +326,7 @@ module Rigor
326
326
  payload = [
327
327
  @labels_by_owner.sort.map { |owner, labels| [owner, labels.sort] },
328
328
  @class_rows.keys.sort_by { |singleton| singleton ? 1 : 0 }
329
- .map { |singleton| [singleton, sorted(@class_rows[singleton])] },
329
+ .map { |singleton| [singleton, sorted(@class_rows[singleton])] },
330
330
  sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
331
331
  @edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
332
332
  @entry_points.map(&:to_h).sort_by { |preset| preset["name"] },
@@ -107,10 +107,9 @@ module Rigor
107
107
  end
108
108
 
109
109
  def walk_namespace(node, prefix)
110
- name = Source::ConstantPath.qualified_name(node.constant_path)
111
- return node.rigor_each_child { |child| walk(child, prefix, false) } if name.nil?
110
+ nested = Source::ConstantPath.declaration_prefix(prefix, node.constant_path)
111
+ return node.rigor_each_child { |child| walk(child, prefix, false) } if nested.nil?
112
112
 
113
- nested = prefix + [name]
114
113
  record_superclass(nested.join("::"), node, prefix) if node.is_a?(Prism::ClassNode)
115
114
  walk(node.body, nested, false) if node.body
116
115
  end
@@ -57,8 +57,12 @@ module Rigor
57
57
  # When non-nil and non-empty, only `sig/` directories whose gem `(name, version, platform)` tuple
58
58
  # matches a lockfile entry are returned. Bundle entries absent from the lockfile (or at a drifted
59
59
  # version) are silently dropped — the lockfile is treated as the source of truth for "what gems this
60
- # project actually declares". Pass `nil` (the default) to keep the pre-Layer-3 behaviour of
61
- # returning every non-skipped `sig/` under the bundle.
60
+ # project actually declares". A git-sourced directory carries no version to compare (see
61
+ # {.gem_name_from_sig_path}), so for those the filter instead requires the matching `locked_gems`
62
+ # entry to have `git_source: true` — name alone is not enough, since a gem that moved off a `git:`
63
+ # source keeps its stale `bundler/gems/` directory under the SAME name until a `bundle clean`. Pass
64
+ # `nil` (the default) to keep the pre-Layer-3 behaviour of returning every non-skipped `sig/` under
65
+ # the bundle.
62
66
  # @return [Array<Pathname>] every `<gem-dir>/sig` directory under the resolved bundle path, minus any
63
67
  # whose gem name is in `skip_gems` and (when `locked_gems` is supplied) minus any whose `(name,
64
68
  # version, platform)` does not match a lockfile entry.
@@ -72,14 +76,41 @@ module Rigor
72
76
  )
73
77
  return [] if resolved.nil?
74
78
 
75
- # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig/` is the canonical bundler layout. `*` on the ruby
76
- # version dir picks up whichever Ruby the bundle was installed for.
77
- all = Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")).map { |d| Pathname.new(d) }
79
+ # Two bundler-install layouts, both under the same `ruby/X.Y.Z/` root (`*` picks up whichever Ruby
80
+ # the bundle was installed for):
81
+ #
82
+ # - `gems/<name>-<ver>/sig/` — the RubyGems-sourced layout, the canonical case.
83
+ # - `bundler/gems/<repo>-<12-hex-revision>/sig/` — `git:`-sourced gems (`Bundler::Source::Git#
84
+ # install_path`); the directory name carries the git repository's basename and a 12-character
85
+ # revision prefix, not the gem name or version. Before this, a fork's own `sig/` was invisible to
86
+ # rigor no matter how it was authored — see issue #611.
87
+ #
88
+ # `path:`-sourced gems are NOT under either glob: Bundler does not copy them into the bundle root at
89
+ # all (`Bundler::Source::Path#path` is the user's own `path:` directory, used in place), so there is
90
+ # no bundle-relative location to walk. A `path:`-sourced gem's `sig/` is only reachable by adding it
91
+ # directly to the project's `signature_paths:` — which already works today and needs no discovery.
92
+ all = (Dir.glob(resolved.join("ruby", "*", "gems", "*", "sig")) +
93
+ Dir.glob(resolved.join("ruby", "*", "bundler", "gems", "*", "sig"))).map { |d| Pathname.new(d) }
78
94
  filtered = all.reject { |sig_dir| skip_gems.include?(gem_name_from_sig_path(sig_dir)) }
79
95
  return filtered if locked_gems.nil? || locked_gems.empty?
80
96
 
81
97
  expected_dirs = expected_gem_dirs(locked_gems)
82
- filtered.select { |sig_dir| expected_dirs.include?(sig_dir.parent.basename.to_s) }
98
+ filtered.select do |sig_dir|
99
+ if git_sourced_layout?(sig_dir)
100
+ # A git install's directory name carries no resolvable version (see above), so the lockfile
101
+ # filter can only check the gem NAME, not `(name, version)` — but name alone is not enough: a
102
+ # gem that used to be `git:`-sourced and has since moved to a released version keeps its old
103
+ # `bundler/gems/<repo>-<sha>/` directory sitting in the bundle tree until a `bundle clean`, and
104
+ # its NAME is still present in the lockfile (now via the rubygems entry). Matching on name only
105
+ # would silently readmit that stale git install — this repo's own `vendor/bundle` had exactly
106
+ # this after `binpacker` moved from `git:` to a released gem. `locked_gems` must say this name is
107
+ # CURRENTLY git-sourced, not merely present.
108
+ locked = locked_gems[gem_name_from_sig_path(sig_dir)]
109
+ !!locked&.git_source
110
+ else
111
+ expected_dirs.include?(sig_dir.parent.basename.to_s)
112
+ end
113
+ end
83
114
  end
84
115
 
85
116
  # `{name => LockedGem}` → set of canonical bundler gem directory basenames. Pure-Ruby gems install as
@@ -98,19 +129,39 @@ module Rigor
98
129
  end
99
130
  private_class_method :expected_gem_dirs
100
131
 
101
- # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`. The gem directory follows the canonical
102
- # `<name>-<version>` pattern; we strip everything from the last hyphen onwards to recover the name.
103
- # (Platform-tagged variants like `ffi-1.17.4-aarch64-linux-gnu/` keep their platform suffix in the
104
- # version part, so the first hyphen from the right is still the name boundary.)
132
+ # `<bundle>/ruby/X.Y.Z/gems/<name>-<ver>/sig` → `<name>`, or
133
+ # `<bundle>/ruby/X.Y.Z/bundler/gems/<repo>-<12-hex-revision>/sig` `<repo>`. The two bundler-install
134
+ # layouts name their gem directory differently, so which suffix to strip depends on which layout
135
+ # `sig_dir` came from:
136
+ #
137
+ # - rubygems layout: `<name>-<version>` (platform-tagged variants like `ffi-1.17.4-aarch64-linux-gnu/`
138
+ # keep their platform suffix in the version part) — the version always starts with a digit, so strip
139
+ # from the first `-` followed by a digit.
140
+ # - git layout: `<repo-basename>-<12-hex-char-revision>` (`Bundler::Source::Git#install_path`) — no
141
+ # version at all, and the repository name need not equal the gem name (a fork hosted under a
142
+ # different repo name, a monorepo). Strip the fixed-width hex suffix instead; best-effort only, since
143
+ # the true gem name isn't recoverable from the filesystem without running Bundler.
105
144
  #
106
145
  # Public so the O4 Layer 3 slice-3 coverage report (`RbsCoverageReport`) can classify discovered bundle
107
146
  # sigs against locked gem names without re-running discovery.
108
147
  def self.gem_name_from_sig_path(sig_dir)
109
148
  gem_dir = sig_dir.parent.basename.to_s
110
- # Strip `-<version>` and any platform suffix. The version always starts with a digit, so split at
111
- # the first `-` followed by a digit.
112
- gem_dir.sub(/-\d.*\z/, "")
149
+ if git_sourced_layout?(sig_dir)
150
+ gem_dir.sub(/-[0-9a-f]{12}\z/, "")
151
+ else
152
+ gem_dir.sub(/-\d.*\z/, "")
153
+ end
154
+ end
155
+
156
+ # True when `sig_dir` sits under the `bundler/gems/` (git-sourced) layout rather than the plain
157
+ # `gems/` (rubygems-sourced) layout — `<bundle>/ruby/X.Y.Z/bundler/gems/<dir>/sig` vs.
158
+ # `<bundle>/ruby/X.Y.Z/gems/<dir>/sig`. A directory-shape check, not a name-shape guess: the two glob
159
+ # roots in {.discover} are already disjoint, so this just recovers which one a given path came from.
160
+ def self.git_sourced_layout?(sig_dir)
161
+ gem_dir = sig_dir.parent
162
+ gem_dir.parent.basename.to_s == "gems" && gem_dir.parent.parent.basename.to_s == "bundler"
113
163
  end
164
+ private_class_method :git_sourced_layout?
114
165
 
115
166
  # Returns `Pathname` resolved bundle path, or `nil` when neither explicit nor auto-detected. Public
116
167
  # for the stats banner so end users can see what rigor picked up.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # Issue #784 — a mutable, first-write-wins record of one shared-build failure, held by the otherwise
6
+ # frozen {Environment} the way {HktRegistryHolder} holds a memoised value. A lazy build that raises
7
+ # during per-file analysis records here at its seam instead of raising into every file's
8
+ # `analyze_body` rescue; the coordinator snapshots the slot after the file loop (the build is lazy, so
9
+ # a snapshot taken before the loop reads nothing) and the aggregator surfaces it once for the run.
10
+ #
11
+ # First-write-wins: the build is memoised, so a second failure can only be the same one re-observed.
12
+ #
13
+ # Concurrency: single-threaded use only, the same discipline as {HktRegistryHolder}.
14
+ class FailureSlot
15
+ def initialize
16
+ @value = nil
17
+ end
18
+
19
+ # @param value [Array] a Marshal-clean tuple — the fork pool ships it back from the worker.
20
+ def record(value)
21
+ @value = value.freeze if @value.nil?
22
+ end
23
+
24
+ # @return [Array, nil] the recorded tuple, or nil when the build never failed.
25
+ attr_reader :value
26
+ end
27
+ end
28
+ end