rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  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 +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -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
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "method_key"
6
+
7
+ module Rigor
8
+ module Effects
9
+ # Where a method key's `def` is written, resolved from the file the key was already traced to
10
+ # (#435).
11
+ #
12
+ # A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
13
+ # and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
14
+ # tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
15
+ # is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
16
+ # command — a warm `rigor effects check` is fast *because* it never parses the project.
17
+ #
18
+ # So this parses the drift's own files and nothing else: an index is built the first time a row asks
19
+ # about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
20
+ # the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
21
+ # reason it can be one layer up is that the caller already knows the file.
22
+ #
23
+ # It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
24
+ # method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
25
+ # which is the same degradation `Positions` makes when it falls back to the class's own source.
26
+ class DefinitionLines
27
+ TOPLEVEL = "<toplevel>"
28
+ private_constant :TOPLEVEL
29
+
30
+ NO_LINES = {}.freeze
31
+ private_constant :NO_LINES
32
+
33
+ # @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
34
+ # @param path [String] the file the key was traced to.
35
+ # @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
36
+ # `def` — an unreadable file, a syntax error, and a synthesized method all land here.
37
+ def for(key:, path:)
38
+ index_for(path)[key]
39
+ end
40
+
41
+ private
42
+
43
+ def index_for(path)
44
+ @indexes ||= {}
45
+ @indexes[path] ||= build_index(path)
46
+ end
47
+
48
+ # First `def` wins: a file that reopens the same method twice has two lines and only one of them is
49
+ # where a reader starts.
50
+ def build_index(path)
51
+ result = Prism.parse_file(path.to_s)
52
+ return NO_LINES unless result.success?
53
+
54
+ {}.tap { |index| walk(result.value, [], singleton: false, index: index) }
55
+ rescue StandardError
56
+ NO_LINES
57
+ end
58
+
59
+ # The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
60
+ # this cannot see, and answering it would need the constant resolution an engine-free path does not
61
+ # have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
62
+ def walk(node, nesting, singleton:, index:)
63
+ case node
64
+ when Prism::ModuleNode, Prism::ClassNode
65
+ name = constant_name(node.constant_path)
66
+ return if name.nil?
67
+
68
+ walk_children(node.body, nesting + [name], singleton: false, index: index)
69
+ when Prism::SingletonClassNode
70
+ walk_children(node.body, nesting, singleton: true, index: index)
71
+ when Prism::DefNode
72
+ record(node, nesting, singleton: singleton, index: index)
73
+ walk_children(node.body, nesting, singleton: singleton, index: index)
74
+ else
75
+ walk_children(node, nesting, singleton: singleton, index: index)
76
+ end
77
+ end
78
+
79
+ def walk_children(node, nesting, singleton:, index:)
80
+ node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
81
+ end
82
+
83
+ def record(node, nesting, singleton:, index:)
84
+ separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
85
+ owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
86
+ key = "#{owner}#{separator}#{node.name}"
87
+ index[key] ||= node.location.start_line
88
+ end
89
+
90
+ def constant_name(node)
91
+ case node
92
+ when Prism::ConstantReadNode then node.name.to_s
93
+ when Prism::ConstantPathNode then node.full_name
94
+ end
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+ end
100
+ end
@@ -83,6 +83,21 @@ module Rigor
83
83
  end
84
84
  end
85
85
 
86
+ # {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
87
+ # are built on the first `.for`, which both judgments reach only once a finding is being
88
+ # constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
89
+ # nothing; that is the whole point of this class existing rather than the pass forcing the
90
+ # tables up front.
91
+ class DeferredPositions
92
+ def initialize(&build)
93
+ @build = build
94
+ end
95
+
96
+ def for(key)
97
+ (@positions ||= @build.call).for(key)
98
+ end
99
+ end
100
+
86
101
  NO_FINDINGS = [].freeze
87
102
  private_constant :NO_FINDINGS
88
103
 
@@ -93,7 +108,9 @@ module Rigor
93
108
  # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
94
109
  # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
95
110
  # to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
96
- # @param positions [Positions] the discovery tables a finding's `def` position is read from.
111
+ # @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
112
+ # position is read from — consulted only when a finding is built, so a deferred value's
113
+ # discovery force is reached exactly as often as a finding exists.
97
114
  # @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
98
115
  # the `--no-tolerated-effects` audit switch.
99
116
  # @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
@@ -112,11 +112,13 @@ module Rigor
112
112
 
113
113
  inherited = envelopes.fetch(ancestor_key)
114
114
  own = envelopes[key]
115
- position = positions.for(key)
115
+ # The position is read inside the two collectors, after they know a finding exists: `.for` is
116
+ # what forces a deferred position table, and an inherited envelope nothing widens must not
117
+ # cost a whole-project discovery parse.
116
118
  if own && !own.top?
117
- collect_declared(findings, key, ancestor_key, inherited, own, position)
119
+ collect_declared(findings, key, ancestor_key, inherited, own, positions)
118
120
  else
119
- collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
121
+ collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
120
122
  end
121
123
  end
122
124
 
@@ -135,16 +137,19 @@ module Rigor
135
137
  nil
136
138
  end
137
139
 
138
- def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
140
+ def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
139
141
  entry = table[key]
140
142
  return if entry.nil?
141
143
 
142
144
  exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
145
+ return if exceeding.empty?
146
+
147
+ path, line = positions.for(key)
143
148
  exceeding.each do |label|
144
149
  trail = PathFinder.shortest(table, symbol: key, label: label)
145
150
  findings << Finding.new(
146
151
  key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
147
- own_envelope: nil, path: position.first, line: position.last,
152
+ own_envelope: nil, path: path, line: line,
148
153
  chain: trail&.chain || [key].freeze, origin: trail&.origin
149
154
  )
150
155
  end
@@ -152,11 +157,15 @@ module Rigor
152
157
 
153
158
  # Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
154
159
  # everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
155
- def collect_declared(findings, key, ancestor_key, inherited, own, position)
156
- own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
160
+ def collect_declared(findings, key, ancestor_key, inherited, own, positions)
161
+ widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
162
+ return if widened.empty?
163
+
164
+ path, line = positions.for(key)
165
+ widened.each do |label|
157
166
  findings << Finding.new(
158
167
  key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
159
- own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
168
+ own_envelope: own, path: path, line: line, chain: nil, origin: nil
160
169
  )
161
170
  end
162
171
  end
@@ -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
@@ -21,10 +21,21 @@ module Rigor
21
21
  DEFAULT_ROOTS = ["sig"].freeze
22
22
 
23
23
  # A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
24
- # the two spellings the envelope reader honours and nothing else, so a signature tree with no
24
+ # the two payloads the envelope reader honours and nothing else, so a signature tree with no
25
25
  # effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
26
26
  # not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
27
- ANNOTATION_HINT = /%a\{\s*(?:pure\s*\}|rigor:v1:effect\b)/
27
+ #
28
+ # RBS accepts five bracket pairs for an annotation, and the reader sees only the text inside
29
+ # them, so the hint must accept all five too: routing on `%a{` alone made a `%a(pure)` project
30
+ # invisible to the run-cache probe, which then served the fast path while a bound existed —
31
+ # the silent-lane shape the #428 family is about. Over-matching (a mismatched closer) is safe:
32
+ # the cost is one declined fast path or one parsed file, never a missed bound.
33
+ ANNOTATION_BRACKETS = { "{" => "}", "(" => ")", "[" => "]", "|" => "|", "<" => ">" }.freeze
34
+ ANNOTATION_HINT = Regexp.union(
35
+ ANNOTATION_BRACKETS.map do |opener, closer|
36
+ /%a#{Regexp.escape(opener)}\s*(?:pure\s*#{Regexp.escape(closer)}|rigor:v1:effect\b)/
37
+ end
38
+ )
28
39
 
29
40
  # A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
30
41
  # a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
@@ -38,7 +38,10 @@ module Rigor
38
38
  class Snapshot
39
39
  # Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
40
40
  # existing file a regeneration event rather than a silent reinterpretation.
41
- SCHEMA = 1
41
+ # 2 — `unresolved:` became a count (#434). A schema-1 file still loads, and its only reported
42
+ # difference is this field, so an existing project sees one regeneration line rather than a
43
+ # reinterpretation.
44
+ SCHEMA = 2
42
45
 
43
46
  HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
44
47
 
@@ -47,8 +50,16 @@ module Rigor
47
50
 
48
51
  # One row of either table.
49
52
  #
50
- # `effects` and `declared` are sorted label arrays; `unresolved` is the sorted list of taint-cause
51
- # renderings behind a false `exhaustive`, and is empty when the row is exhaustive.
53
+ # `effects` and `declared` are sorted label arrays; `unresolved` is HOW MANY taint causes sit behind
54
+ # a false `exhaustive`, and is zero when the row is exhaustive.
55
+ #
56
+ # A count rather than the causes themselves (#434). On redmine the cause renderings were 144,771 of
57
+ # the file's 293,276 bytes, on lines up to 821 characters — simultaneously the half a reviewer
58
+ # cannot read and the half that churns when an unrelated call moves. This file already makes that
59
+ # argument about origins ("a record keyed by them would churn on every refactor"); the causes are
60
+ # the same class of thing and were the one place it was not applied. What a reviewer needs from the
61
+ # record is the stable fact that the row is not exhaustive and by how much, which is what a count
62
+ # is; `rigor effects explain` names the causes on demand, against the live table.
52
63
  class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
53
64
  def exhaustive?
54
65
  exhaustive
@@ -61,7 +72,7 @@ module Rigor
61
72
  row = { "effects" => effects }
62
73
  row["declared"] = declared unless declared.empty?
63
74
  row["exhaustive"] = false unless exhaustive
64
- row["unresolved"] = unresolved unless unresolved.empty?
75
+ row["unresolved"] = unresolved unless unresolved.zero?
65
76
  row
66
77
  end
67
78
 
@@ -73,10 +84,23 @@ module Rigor
73
84
  effects: string_list(row["effects"], key, "effects"),
74
85
  declared: string_list(row["declared"], key, "declared"),
75
86
  exhaustive: row.fetch("exhaustive", true) != false,
76
- unresolved: string_list(row["unresolved"], key, "unresolved")
87
+ unresolved: unresolved_count(row["unresolved"], key)
77
88
  )
78
89
  end
79
90
 
91
+ # Tolerant of the schema-1 spelling, which was the list itself: the field's whole point now is a
92
+ # stable number, and a schema-1 file must LOAD so the header's own mismatch can be reported as
93
+ # the one regeneration event it is. Refusing to parse it would turn a migration into an error.
94
+ def self.unresolved_count(value, key)
95
+ case value
96
+ when nil then 0
97
+ when Integer then value
98
+ when Array then value.length
99
+ else raise ParseError, "#{key.inspect} unresolved: expected a count, got #{value.class}"
100
+ end
101
+ end
102
+ private_class_method :unresolved_count
103
+
80
104
  def self.string_list(value, key, field)
81
105
  return [].freeze if value.nil?
82
106
  raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
@@ -185,6 +209,29 @@ module Rigor
185
209
  parse(File.read(path, encoding: "UTF-8"))
186
210
  end
187
211
 
212
+ # A path as a reader sees it: project-relative when it is under the root, absolute when it is not.
213
+ #
214
+ # Public for the same reason {.render_causes} is — `reach:`'s entry-point globs and a drift row's
215
+ # source suffix (#435) must agree about what a project-relative path is, and a report that leaked
216
+ # `/private/tmp/nix-shell.../loud.rb` into a reviewer's terminal would be answering with this
217
+ # machine's layout rather than with the project's.
218
+ def relativize(path, project_root)
219
+ absolute = File.absolute_path(path.to_s)
220
+ root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
221
+ absolute.start_with?(root) ? absolute[root.length..] : absolute
222
+ end
223
+
224
+ # The taint causes as a reviewer reads them, from the closed enum of
225
+ # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`.
226
+ #
227
+ # Public because two surfaces render the same causes and must not disagree about what one IS:
228
+ # a row's `unresolved:` count is the length of this list, and `rigor effects explain` prints the
229
+ # list itself for an `exhaustive → not` drift row (#434 / #435). One implementation, as WD3 asks.
230
+ def render_causes(causes)
231
+ causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
232
+ .uniq.sort.freeze
233
+ end
234
+
188
235
  private
189
236
 
190
237
  def parse_header(data)
@@ -241,12 +288,6 @@ module Rigor
241
288
  end
242
289
  end
243
290
 
244
- def relativize(path, project_root)
245
- absolute = File.absolute_path(path.to_s)
246
- root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
247
- absolute.start_with?(root) ? absolute[root.length..] : absolute
248
- end
249
-
250
291
  # Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
251
292
  # `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
252
293
  # its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
@@ -261,19 +302,10 @@ module Rigor
261
302
  effects: proven.to_a,
262
303
  declared: declared.excluding_subsumed_by(proven).to_a,
263
304
  exhaustive: exhaustive,
264
- unresolved: exhaustive ? [].freeze : render_causes(causes)
305
+ unresolved: exhaustive ? 0 : render_causes(causes).length
265
306
  )
266
307
  end
267
308
 
268
- # `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
269
- # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
270
- # design note sketched this field as "call names"; the collector keeps causes rather than names,
271
- # and for the causes that have one the detail already IS the call name.
272
- def render_causes(causes)
273
- causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
274
- .uniq.sort.freeze
275
- end
276
-
277
309
  # A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
278
310
  # accessors that join it when discovery synthesises them — carries no information a reviewer acts
279
311
  # on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
@@ -107,6 +107,7 @@ module Rigor
107
107
  @undischarged = undischarged
108
108
  @added_symbols = 0
109
109
  @removed_symbols = 0
110
+ @suppressed = 0
110
111
  @events = build_events.freeze
111
112
  freeze
112
113
  end
@@ -123,9 +124,16 @@ module Rigor
123
124
  end
124
125
 
125
126
  # Renames are a removal plus an addition and are never reported as a lost effect; the footer is
126
- # where the reviewer sees that the two counts balance.
127
+ # where the reviewer sees that the two counts balance. `suppressed` is the per-symbol events a
128
+ # regeneration event withheld — zero on an ordinary comparison.
127
129
  def footer
128
- { added_symbols: @added_symbols, removed_symbols: @removed_symbols }
130
+ { added_symbols: @added_symbols, removed_symbols: @removed_symbols, suppressed: @suppressed }
131
+ end
132
+
133
+ # Whether the two sides were computed under different rules (#434). The per-symbol comparison is
134
+ # then meaningless rather than merely noisy, which is why {#build_events} withholds it.
135
+ def regeneration?
136
+ @events.any? { |event| event.category == REGENERATION }
129
137
  end
130
138
 
131
139
  def events_for(table)
@@ -145,10 +153,25 @@ module Rigor
145
153
  ADDITIVE_CATEGORIES.include?(event.category)
146
154
  end
147
155
 
156
+ # A regeneration event withholds the per-symbol comparison rather than printing it (#434).
157
+ #
158
+ # The header says the record was written under different rules, so the two sides are not
159
+ # comparable — a claim this class already makes in its own documentation and then contradicted by
160
+ # emitting every row anyway. On redmine a moved `config_digest:` produced one regeneration line
161
+ # followed by 482 `-symbol` lines, none of which was a review signal: they say the recorded set was
162
+ # computed differently, which the header already said once.
163
+ #
164
+ # The table comparison still RUNS, because its per-symbol counters are what tell the reader the
165
+ # scale of what is withheld. Only the events are dropped.
148
166
  def build_events
149
167
  return [missing_snapshot_event] if @recorded.nil?
150
168
 
151
- header_events + TABLES.flat_map { |table| table_events(table) }
169
+ header = header_events
170
+ table = TABLES.flat_map { |name| table_events(name) }
171
+ return table if header.empty?
172
+
173
+ @suppressed = table.length
174
+ header
152
175
  end
153
176
 
154
177
  def missing_snapshot_event
@@ -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.
@@ -22,13 +22,19 @@ module Rigor
22
22
  #
23
23
  # `version` is the resolved version string (e.g. "8.0.1"); `platform` is the lockfile's platform tag,
24
24
  # normalised to `"ruby"` when the lockfile records `ruby` and to the raw String otherwise (e.g.
25
- # "aarch64-linux-gnu").
26
- LockedGem = Data.define(:name, :version, :platform) do
27
- def initialize(name:, version:, platform:)
25
+ # "aarch64-linux-gnu"). `git_source` is true when the lockfile's `GIT` stanza (rather than `GEM`)
26
+ # resolved this gem — see {BundleSigDiscovery}, which needs it to tell a genuine git install apart
27
+ # from a same-named gem the bundle tree has an out-of-band `bundler/gems/` leftover for (e.g. a
28
+ # dependency that moved from a `git:` source to a released version without a `bundle clean`, so its
29
+ # git-installed directory is still sitting there under the OLD source's name). Defaults `false` so
30
+ # existing callers that construct a `LockedGem` without an opinion stay conservative.
31
+ LockedGem = Data.define(:name, :version, :platform, :git_source) do
32
+ def initialize(name:, version:, platform:, git_source: false)
28
33
  super(
29
34
  name: -name.to_s,
30
35
  version: -version.to_s,
31
- platform: -platform.to_s
36
+ platform: -platform.to_s,
37
+ git_source: git_source ? true : false
32
38
  )
33
39
  end
34
40
  end
@@ -94,8 +100,12 @@ module Rigor
94
100
  # (references/rbs/sig/shims/bundler.rbs) does NOT declare `LazySpecification#platform` so the call
95
101
  # site needs a suppression marker.
96
102
  platform = spec.platform.to_s # rigor:disable undefined-method
103
+ # `#source` is the `Bundler::Source::*` this spec resolved from — `Git` for a `GIT` lockfile
104
+ # stanza, `Rubygems` for a `GEM` one, `Path`/`Gemspec` for `path:`. Only `Git` sources land under
105
+ # {BundleSigDiscovery}'s `bundler/gems/` walk.
97
106
  h[spec.name.to_s] = LockedGem.new(
98
- name: spec.name, version: spec.version.to_s, platform: platform
107
+ name: spec.name, version: spec.version.to_s, platform: platform,
108
+ git_source: spec.source.is_a?(Bundler::Source::Git)
99
109
  )
100
110
  end
101
111
  locked.freeze