rigortype 0.4.1 → 0.4.3

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 (230) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +18 -5
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/02-cli-reference.md +19 -1
  27. data/docs/manual/03-configuration.md +7 -5
  28. data/docs/manual/04-diagnostics.md +2 -1
  29. data/docs/manual/05-inspecting-types.md +50 -0
  30. data/docs/manual/06-baseline.md +2 -0
  31. data/docs/manual/07-plugins.md +1 -1
  32. data/docs/manual/12-caching.md +18 -1
  33. data/docs/manual/14-rails-quickstart.md +2 -2
  34. data/docs/manual/18-removing-dead-code.md +41 -4
  35. data/docs/manual/plugins/README.md +6 -1
  36. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  37. data/docs/manual/plugins/rigor-alba.md +60 -0
  38. data/docs/manual/plugins/rigor-minitest.md +22 -4
  39. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  40. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  41. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  42. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  43. data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
  44. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  45. data/lib/rigor/analysis/check_rules.rb +292 -59
  46. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  47. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  48. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  49. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  50. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  51. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  52. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  53. data/lib/rigor/analysis/incremental.rb +29 -9
  54. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  55. data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
  56. data/lib/rigor/analysis/incremental_session.rb +103 -23
  57. data/lib/rigor/analysis/path_expansion.rb +62 -6
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  59. data/lib/rigor/analysis/reachability/graph.rb +241 -37
  60. data/lib/rigor/analysis/reachability/scan.rb +490 -77
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  64. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  65. data/lib/rigor/analysis/run_stats.rb +28 -6
  66. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +113 -6
  67. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
  68. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  70. data/lib/rigor/analysis/runner.rb +62 -13
  71. data/lib/rigor/analysis/worker_session.rb +8 -5
  72. data/lib/rigor/cache/engine_source.rb +21 -7
  73. data/lib/rigor/cache/incremental_snapshot.rb +55 -5
  74. data/lib/rigor/cli/annotate_command.rb +5 -0
  75. data/lib/rigor/cli/baseline_command.rb +7 -18
  76. data/lib/rigor/cli/check_command.rb +140 -40
  77. data/lib/rigor/cli/docs_command.rb +5 -4
  78. data/lib/rigor/cli/lens_command.rb +144 -0
  79. data/lib/rigor/cli/lsp_command.rb +5 -2
  80. data/lib/rigor/cli/skill_command.rb +3 -3
  81. data/lib/rigor/cli/skill_describe.rb +1 -1
  82. data/lib/rigor/cli/trace_command.rb +5 -0
  83. data/lib/rigor/cli/type_of_command.rb +6 -3
  84. data/lib/rigor/cli/unused_command.rb +166 -27
  85. data/lib/rigor/cli.rb +39 -5
  86. data/lib/rigor/configuration/severity_profile.rb +6 -0
  87. data/lib/rigor/configuration.rb +61 -2
  88. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  89. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  90. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  91. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  92. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  93. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  94. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  95. data/lib/rigor/environment/rbs_loader.rb +316 -14
  96. data/lib/rigor/environment/required_features.rb +208 -0
  97. data/lib/rigor/environment.rb +33 -8
  98. data/lib/rigor/inference/acceptance.rb +28 -3
  99. data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
  100. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  101. data/lib/rigor/inference/captured_locals.rb +43 -3
  102. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  103. data/lib/rigor/inference/expression_typer.rb +261 -29
  104. data/lib/rigor/inference/global_write_census.rb +7 -4
  105. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  106. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  107. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  108. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
  109. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  110. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  111. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  112. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  113. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  114. data/lib/rigor/inference/match_rebinding.rb +1 -1
  115. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  116. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  117. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  118. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  119. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  120. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  121. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  122. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  123. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  124. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  125. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  126. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  127. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  128. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
  129. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  130. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  131. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  132. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  133. data/lib/rigor/inference/method_dispatcher.rb +96 -14
  134. data/lib/rigor/inference/module_function_state.rb +2 -1
  135. data/lib/rigor/inference/narrowing.rb +20 -6
  136. data/lib/rigor/inference/object_mixins.rb +180 -0
  137. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  138. data/lib/rigor/inference/project_patched_methods.rb +12 -7
  139. data/lib/rigor/inference/project_patched_scanner.rb +108 -26
  140. data/lib/rigor/inference/refine_census.rb +239 -0
  141. data/lib/rigor/inference/refine_self.rb +317 -0
  142. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  143. data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
  144. data/lib/rigor/inference/refined_dispatch.rb +320 -0
  145. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  146. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  147. data/lib/rigor/inference/scope_indexer.rb +827 -65
  148. data/lib/rigor/inference/statement_evaluator.rb +294 -51
  149. data/lib/rigor/inference/string_mutation.rb +9 -3
  150. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  151. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  152. data/lib/rigor/inference/version_guard.rb +19 -10
  153. data/lib/rigor/language_server/project_context.rb +87 -5
  154. data/lib/rigor/language_server/server.rb +18 -0
  155. data/lib/rigor/lens/anchor.rb +115 -0
  156. data/lib/rigor/lens/contract_reader.rb +118 -0
  157. data/lib/rigor/lens/document.rb +113 -0
  158. data/lib/rigor/lens/file_reader.rb +433 -0
  159. data/lib/rigor/lens/members.rb +153 -0
  160. data/lib/rigor/lens/parameters.rb +91 -0
  161. data/lib/rigor/lens/plugins.rb +40 -0
  162. data/lib/rigor/lens/run.rb +280 -0
  163. data/lib/rigor/lens/slot.rb +79 -0
  164. data/lib/rigor/lens.rb +40 -0
  165. data/lib/rigor/plugin/inflector.rb +1 -1
  166. data/lib/rigor/plugin/io_boundary.rb +42 -3
  167. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  168. data/lib/rigor/project_environment.rb +48 -6
  169. data/lib/rigor/protection/mutation_cache.rb +3 -2
  170. data/lib/rigor/protection/mutator.rb +1 -1
  171. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  172. data/lib/rigor/reflection/constant_path.rb +13 -2
  173. data/lib/rigor/reflection.rb +40 -8
  174. data/lib/rigor/scope/discovery_index.rb +40 -1
  175. data/lib/rigor/scope/resolution_chain.rb +159 -11
  176. data/lib/rigor/scope.rb +211 -21
  177. data/lib/rigor/sig_gen/classification.rb +4 -1
  178. data/lib/rigor/sig_gen/generator.rb +100 -10
  179. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  180. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  181. data/lib/rigor/source/alias_names.rb +52 -0
  182. data/lib/rigor/source/literals.rb +3 -2
  183. data/lib/rigor/source/symbol_name.rb +20 -0
  184. data/lib/rigor/type/combinator.rb +34 -2
  185. data/lib/rigor/type/float_range.rb +4 -1
  186. data/lib/rigor/type/nominal.rb +6 -1
  187. data/lib/rigor/version.rb +1 -1
  188. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  189. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  190. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  191. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  192. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  193. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  194. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  195. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  196. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  197. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  198. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  199. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  200. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  201. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  202. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  203. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  204. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  205. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  206. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  207. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  208. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  209. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  210. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  211. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  212. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  213. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  214. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  215. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  216. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  217. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  218. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  219. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  220. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  221. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  222. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  223. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  224. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  225. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  226. data/sig/rigor/scope.rbs +30 -4
  227. data/sig/rigor/source.rbs +4 -0
  228. data/sig/rigor.rbs +9 -4
  229. data/skills/rigor-project-init/references/02-configure.md +2 -2
  230. metadata +83 -1
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # Issue #1713 — loading a {RequiredFeatures}-gated vendored directory minus only what another signature
8
+ # source already declares, instead of standing the whole directory down.
9
+ #
10
+ # `RbsLoader.build_env_for` first tries the directory whole. When that costs the environment something
11
+ # (`RbsLoader.gated_failures`), it builds the environment WITHOUT the directory and plans against it with
12
+ # {.build}: the plan is the directory's parsed declarations, rewritten in memory (the files on disk are never
13
+ # touched), so that
14
+ #
15
+ # - a method, attribute or alias another source declares on the same class is left out (an overload
16
+ # continuation, `def m: ... | ...`, declares nothing of its own and needs the vendored base, so it keeps
17
+ # it); a member declaring two methods loses only the one taken — an `attr_accessor` narrows to its reader
18
+ # or writer, a `def self?.m` to its instance or singleton side;
19
+ # - a type whose header disagrees with another source's — a class one side declares as a module, or a
20
+ # different generic arity — stands down as a whole: its declaration becomes a *shell*, the other source's
21
+ # header with no members and no superclass, which keeps the vendored types nested inside it in their
22
+ # lexical context;
23
+ # - a type in `shelled` (the types the trial build of a previous plan still failed for) is shelled the same
24
+ # way when another source declares it. When none does — the type failed because something it names was
25
+ # displaced, a superclass the project redeclared as a module, say — it keeps its own members and nested
26
+ # types but loses its superclass, and is stamped as a Rigor-synthesized stub type
27
+ # (`RbsLoader::SYNTHETIC_STUB_BUFFER`): its own members still type, and a call it no longer answers reads
28
+ # `Dynamic[top]` rather than drawing `call.undefined-method` for a method it inherits at runtime.
29
+ #
30
+ # The kept declarations load where the directory would have loaded, ahead of the project's signatures, so
31
+ # which declaration is a class's primary one does not move; the shells load after them, so a shell never
32
+ # becomes the primary declaration of a class the project declares. The trial build stays the arbiter:
33
+ # `build_env_for` keeps a plan's environment only when every gated type builds in it.
34
+ #
35
+ # {.standdowns} reads what stood down back off a built environment, the way the other `rbs.coverage.*`
36
+ # notices are derived, so a warm run off the env cache reports what the cold run reported.
37
+ module GatedSignaturePlan
38
+ # Buffer name stamped on the shell declarations. Never a real path, so {.standdowns} tells a shell from the
39
+ # vendored declaration it replaced by it.
40
+ SHELL_BUFFER = "(rigor: vendored stand-down shells)"
41
+
42
+ # The buffer names Rigor synthesizes declarations under. They stand for no source, so neither a plan nor
43
+ # a stand-down report reads them as another source's declaration.
44
+ def self.synthetic_buffers
45
+ [SHELL_BUFFER, RbsLoader::SYNTHETIC_NAMESPACE_BUFFER, RbsLoader::SYNTHETIC_STUB_BUFFER]
46
+ end
47
+
48
+ # A planned load: `leading` and `shells` are `[buffer, directives, decls]` sources, `changed` is false
49
+ # when the plan is the directory as it stands.
50
+ Plan = Data.define(:leading, :shells, :changed)
51
+
52
+ # One plan's walk over the vendored declarations: the environment it plans against, the types to shell
53
+ # whatever their headers, and whether anything was left out.
54
+ class Planner
55
+ attr_reader :changed
56
+
57
+ def initialize(base_env, shelled)
58
+ @base_env = base_env
59
+ @shelled = shelled
60
+ @changed = false
61
+ end
62
+
63
+ # Splits `decls` into what loads ahead of the project's signatures and what loads after them.
64
+ def plan_decls(decls, namespace)
65
+ lead = []
66
+ shell = []
67
+ decls.each do |decl|
68
+ unless GatedSignaturePlan.type_declaration?(decl)
69
+ lead << decl
70
+ next
71
+ end
72
+
73
+ kept, shelled = plan_type(decl, GatedSignaturePlan.qualify(namespace, decl.name))
74
+ lead << kept if kept
75
+ shell << shelled if shelled
76
+ end
77
+ [lead, shell]
78
+ end
79
+
80
+ private
81
+
82
+ def plan_type(decl, name)
83
+ others = GatedSignaturePlan.other_declarations(@base_env, name)
84
+ nested = decl.members.select { |member| GatedSignaturePlan.type_declaration?(member) }
85
+ nested_lead, nested_shell = plan_decls(nested, name)
86
+ if GatedSignaturePlan.header_conflict?(decl, others) || (@shelled.include?(name) && !others.empty?)
87
+ @changed = true
88
+ return [nil, GatedSignaturePlan.header_copy(others.first, decl.name, nested_lead + nested_shell)]
89
+ end
90
+ return [nil, open_type(decl, nested_lead + nested_shell)] if @shelled.include?(name)
91
+
92
+ wrapper = nested_shell.empty? ? nil : GatedSignaturePlan.header_copy(decl, decl.name, nested_shell)
93
+ [keep_type(decl, others, nested_lead), wrapper]
94
+ end
95
+
96
+ # `decl` less the methods `others` declare, with its nested types replaced by their planned kept halves.
97
+ def keep_type(decl, others, nested_lead)
98
+ taken = GatedSignaturePlan.declared_keys(others)
99
+ own = decl.members.reject { |member| GatedSignaturePlan.type_declaration?(member) }
100
+ members = own.filter_map { |member| GatedSignaturePlan.untaken(member, taken) }
101
+ @changed ||= members.size != own.size || members.zip(own).any? { |kept, member| !kept.equal?(member) }
102
+ GatedSignaturePlan.rebuild(decl, members: members + nested_lead)
103
+ end
104
+
105
+ # A type no other source declares that still failed to build: its own members, no superclass, stamped as a
106
+ # synthesized stub type so what it no longer inherits reads untyped.
107
+ def open_type(decl, nested)
108
+ @changed = true
109
+ own = decl.members.reject { |member| GatedSignaturePlan.type_declaration?(member) }
110
+ GatedSignaturePlan.header_copy(decl, decl.name, own + nested, buffer_name: RbsLoader::SYNTHETIC_STUB_BUFFER)
111
+ end
112
+ end
113
+
114
+ module_function
115
+
116
+ # @param roots — the gated directories (Pathnames) to plan, each loaded whole on the first attempt.
117
+ # @param base_env — the environment built without them: every other signature source, resolved.
118
+ # @param shelled — absolute-less type names to shell regardless of their headers.
119
+ def build(roots, base_env, shelled = Set.new)
120
+ planner = Planner.new(base_env, shelled)
121
+ leading = []
122
+ shells = []
123
+ signature_files(roots).each do |file|
124
+ buffer, directives, decls = RbsLoader.parse_signature_file(file)
125
+ next if buffer.nil?
126
+
127
+ lead, shell = planner.plan_decls(decls, nil)
128
+ leading << [buffer, directives, lead]
129
+ shells << [shell_buffer, directives, shell] unless shell.empty?
130
+ end
131
+ Plan.new(leading: leading, shells: shells, changed: planner.changed)
132
+ end
133
+
134
+ def signature_files(roots)
135
+ roots.flat_map { |root| Dir.glob(File.join(root.to_s, "*.rbs")).sort }
136
+ end
137
+
138
+ def shell_buffer
139
+ ::RBS::Buffer.new(name: SHELL_BUFFER, content: "")
140
+ end
141
+
142
+ # The declarations of `name` in `env` that some other source made, synthesized stubs left out.
143
+ def other_declarations(env, name)
144
+ entry = env&.class_decls&.[](::RBS::TypeName.parse("::#{name}"))
145
+ return [] if entry.nil?
146
+
147
+ RbsLoader.entry_declarations(entry).reject { |decl| synthetic?(decl) }
148
+ end
149
+
150
+ def synthetic?(decl)
151
+ synthetic_buffers.include?(RbsLoader.declaration_buffer_name(decl))
152
+ end
153
+
154
+ # A class one side declares as a module, or a different number of type parameters. A disagreement the
155
+ # declarations do not show this plainly — a superclass, a variance, a bound — is the trial build's to find.
156
+ def header_conflict?(decl, others)
157
+ first = others.first
158
+ return false if first.nil?
159
+ return true unless first.instance_of?(decl.class)
160
+
161
+ first.type_params.size != decl.type_params.size
162
+ end
163
+
164
+ def declared_keys(others)
165
+ others.each_with_object(Set.new) do |other, keys|
166
+ other.members.each { |member| RbsLoader.member_method_keys(member).each { |key| keys << key } }
167
+ end
168
+ end
169
+
170
+ # `member` less the methods in `taken`: itself when it declares none of them, nil when it declares only
171
+ # them, else narrowed to the one it declares that is not taken.
172
+ def untaken(member, taken)
173
+ keys = RbsLoader.member_method_keys(member)
174
+ left = keys.reject { |key| taken.include?(key) }
175
+ return member if left.size == keys.size
176
+ return nil if left.empty?
177
+
178
+ narrowed(member, left.first)
179
+ end
180
+
181
+ # The two members that declare two methods: an `attr_accessor` (a reader and a writer) and a
182
+ # `def self?.m` (an instance and a singleton method). The instance side of a `self?` method is private
183
+ # (a module function), so narrowing to it keeps that.
184
+ def narrowed(member, key)
185
+ method_name, kind = key
186
+ if member.is_a?(::RBS::AST::Members::MethodDefinition)
187
+ return member.update(kind: kind, visibility: kind == :instance ? :private : member.visibility)
188
+ end
189
+
190
+ attribute = method_name.to_s.end_with?("=") ? ::RBS::AST::Members::AttrWriter : ::RBS::AST::Members::AttrReader
191
+ attribute.new(
192
+ name: member.name, type: member.type, ivar_name: member.ivar_name, kind: member.kind,
193
+ annotations: member.annotations, location: member.location, comment: member.comment,
194
+ visibility: member.visibility
195
+ )
196
+ end
197
+
198
+ # `source`'s kind and type parameters under `name`, carrying only `members` — no superclass, self types,
199
+ # annotations or members of its own, all of which a reopening declaration may omit.
200
+ def header_copy(source, name, members, buffer_name: SHELL_BUFFER)
201
+ location = ::RBS::Location.new(::RBS::Buffer.new(name: buffer_name, content: ""), 0, 0)
202
+ if source.is_a?(::RBS::AST::Declarations::Class)
203
+ ::RBS::AST::Declarations::Class.new(
204
+ name: name, type_params: source.type_params, super_class: nil, members: members,
205
+ annotations: [], location: location, comment: nil
206
+ )
207
+ else
208
+ ::RBS::AST::Declarations::Module.new(
209
+ name: name, type_params: source.type_params, members: members, self_types: [],
210
+ annotations: [], location: location, comment: nil
211
+ )
212
+ end
213
+ end
214
+
215
+ def rebuild(decl, members:)
216
+ if decl.is_a?(::RBS::AST::Declarations::Class)
217
+ ::RBS::AST::Declarations::Class.new(
218
+ name: decl.name, type_params: decl.type_params, super_class: decl.super_class, members: members,
219
+ annotations: decl.annotations, location: decl.location, comment: decl.comment
220
+ )
221
+ else
222
+ ::RBS::AST::Declarations::Module.new(
223
+ name: decl.name, type_params: decl.type_params, members: members, self_types: decl.self_types,
224
+ annotations: decl.annotations, location: decl.location, comment: decl.comment
225
+ )
226
+ end
227
+ end
228
+
229
+ def type_declaration?(decl)
230
+ decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
231
+ end
232
+
233
+ def qualify(namespace, type_name)
234
+ text = type_name.to_s
235
+ return text.delete_prefix("::") if text.start_with?("::") || namespace.nil?
236
+
237
+ "#{namespace}::#{text}"
238
+ end
239
+
240
+ # What stood down from each of `roots` in `env`, as `[dir, entries, whole]`: `entries` are
241
+ # `[declaration, cause_file, reason]`, `declaration` reading `Integer#prime?`, `Integer.from_prime_division`
242
+ # or `Prime` (a whole type), `cause_file` the buffer name of the other source's declaration, or nil when it
243
+ # cannot be named, and `reason` one of `"member"` (another source declares that method), `"type"` (the
244
+ # type stood down whole, its header contradicted or failing beside another source's declaration) or
245
+ # `"open"` (it failed with no other source declaring it, and was kept without its superclass, unchecked).
246
+ # `whole` is true when the directory stood down entirely; its entries are then only the clashes the
247
+ # environment still shows. A directory nothing stood down from is left out.
248
+ #
249
+ # @param roots — `{dir_basename => Pathname}` of the gated directories the run activated.
250
+ # @param project_files — the project's own signature files: a cause is named from them (or from inline
251
+ # RBS) in preference to another source, and only from them when the type failed rather than clashed.
252
+ def standdowns(env, roots, project_files = Set.new)
253
+ return [] if env.nil?
254
+
255
+ loaded = RbsLoader.loaded_signature_buffers(env)&.to_set { |buffer| buffer.name.to_s } || Set.new
256
+ roots.sort.filter_map do |dir, root|
257
+ files = signature_files([root])
258
+ whole = files.none? { |file| loaded.include?(file) }
259
+ entries = files.flat_map { |file| file_standdowns(env, file, whole, project_files) }.uniq
260
+ [dir, entries, whole] if whole || !entries.empty?
261
+ end
262
+ rescue ::RBS::BaseError
263
+ []
264
+ end
265
+
266
+ def file_standdowns(env, file, whole, project_files)
267
+ _buffer, _directives, decls = RbsLoader.parse_signature_file(file)
268
+ return [] if decls.nil?
269
+
270
+ entries = []
271
+ each_type(decls, nil) do |decl, name|
272
+ entries.concat(type_standdowns(env, file, [decl, name], whole, project_files))
273
+ end
274
+ entries
275
+ end
276
+
277
+ def each_type(decls, namespace, &block)
278
+ decls.each do |decl|
279
+ next unless type_declaration?(decl)
280
+
281
+ name = qualify(namespace, decl.name)
282
+ block.call(decl, name)
283
+ each_type(decl.members, name, &block)
284
+ end
285
+ end
286
+
287
+ def type_standdowns(env, file, (decl, name), whole, project_files)
288
+ entry = env.class_decls[::RBS::TypeName.parse("::#{name}")]
289
+ declarations = entry.nil? ? [] : RbsLoader.entry_declarations(entry)
290
+ vendored = declarations.select { |other| RbsLoader.declaration_buffer_name(other) == file }
291
+ others = declarations.reject { |other| vendored.include?(other) || synthetic?(other) }
292
+ # A partial load shells a type that stood down, so its vendored declaration is gone; a whole stand-down
293
+ # removed every vendored declaration, and only a header the environment still contradicts names a type.
294
+ conflict = header_conflict?(decl, others)
295
+ type_row = [[name, type_cause(others, conflict, project_files), "type"]]
296
+ return conflict ? type_row : member_standdowns(decl, name, [], others, true) if whole
297
+ return member_standdowns(decl, name, vendored, others, false) unless vendored.empty?
298
+ return [[name, nil, "open"]] if others.empty? && declarations.any? { |other| stub?(other) }
299
+
300
+ type_row
301
+ end
302
+
303
+ # A header clash names the declaration it clashed with, preferring the project's own; a type that only
304
+ # failed beside another source names a project or inline declaration of it, never core or a gem's copy.
305
+ def type_cause(others, conflict, project_files)
306
+ own = others.find do |other|
307
+ name = buffer_of(other).to_s
308
+ project_files.include?(name) || name.start_with?("virtual:")
309
+ end
310
+ buffer_of(own || (conflict ? others.first : nil))
311
+ end
312
+
313
+ def stub?(decl)
314
+ RbsLoader.declaration_buffer_name(decl) == RbsLoader::SYNTHETIC_STUB_BUFFER
315
+ end
316
+
317
+ def member_standdowns(decl, name, vendored, others, whole)
318
+ present = declared_keys(vendored)
319
+ decl.members.flat_map do |member|
320
+ RbsLoader.member_method_keys(member).reject { |key| present.include?(key) }.filter_map do |key|
321
+ cause = others.find { |other| other.members.any? { |m| RbsLoader.member_method_keys(m).include?(key) } }
322
+ next if whole && cause.nil?
323
+
324
+ [member_label(name, key), buffer_of(cause), "member"]
325
+ end
326
+ end
327
+ end
328
+
329
+ def member_label(name, key)
330
+ method_name, kind = key
331
+ "#{name}#{kind == :singleton ? '.' : '#'}#{method_name}"
332
+ end
333
+
334
+ def buffer_of(decl)
335
+ decl && RbsLoader.declaration_buffer_name(decl)
336
+ end
337
+ end
338
+ end
339
+ end
@@ -35,11 +35,12 @@ module Rigor
35
35
  found.freeze
36
36
  end
37
37
 
38
- # `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/` — the same pure-filesystem layout
38
+ # `<bundle>/ruby/X.Y.Z/gems/<name>-<version>[-<platform>]/` — the same pure-filesystem layout
39
39
  # {MissingGemConstantIndex.bundle_gem_dirs} walks, preferred over the host's gems so a project that
40
40
  # vendored its bundle without committing the lockfile is read from its own tree. A platform-tagged
41
- # directory (`ffi-1.17.4-aarch64-linux-gnu`) does not split into a name/version pair and is skipped:
42
- # those gems ship native code, not the pure-Ruby constants this feeds.
41
+ # directory (`commonmarker-2.10.0-arm64-darwin`) is kept with its platform as a variant: those builds
42
+ # ship the same `lib/<name>.rb` and top-level namespace as the `ruby` one, and the index looks the
43
+ # directory up by that variant. A name installed more than once keeps every directory as a variant.
43
44
  def from_bundle_tree(bundle_path)
44
45
  return {} if bundle_path.nil?
45
46
 
@@ -47,10 +48,15 @@ module Rigor
47
48
  return {} unless base.directory?
48
49
 
49
50
  Dir.glob(base.join("ruby", "*", "gems", "*")).each_with_object({}) do |dir, acc|
50
- name, version = split_name_version(File.basename(dir))
51
+ name, version, platform = split_install_dirname(File.basename(dir))
51
52
  next unless name
52
53
 
53
- acc[name] ||= locked_gem(name, version)
54
+ prior = acc[name]
55
+ acc[name] = if prior
56
+ prior.with(variants: [*prior.variants, [version, platform]])
57
+ else
58
+ locked_gem(name, version, platform)
59
+ end
54
60
  end
55
61
  end
56
62
 
@@ -70,15 +76,36 @@ module Rigor
70
76
  {}
71
77
  end
72
78
 
73
- def locked_gem(name, version)
74
- LockfileResolver::LockedGem.new(name: name, version: version, platform: "ruby")
79
+ def locked_gem(name, version, platform = "ruby")
80
+ LockfileResolver::LockedGem.new(name: name, version: version, platform: platform)
75
81
  end
76
82
 
77
- # Splits a gem install directory's basename. The version tail is anchored on a leading digit and must
78
- # carry no further hyphen, which is what excludes the platform-tagged variants above.
79
- def split_name_version(basename)
83
+ # Splits a gem install directory's basename into `[name, version, platform]`, or nil. The version
84
+ # always starts with a digit and carries no hyphen; a platform tail is accepted only when RubyGems
85
+ # recognises its OS (`arm64-darwin`, `x86_64-linux-gnu`, `java`), so `foo-2fa-1.0` stays the plain gem
86
+ # `foo-2fa`. The platform split is tried first, because a plain split of `foo-1.0-universal-darwin-23`
87
+ # would claim the name `foo-1.0-universal-darwin`; and from the rightmost version-shaped segment,
88
+ # because RubyGems also reads `1.0-x86_64-linux` as a linux platform, which would cut
89
+ # `foo-2fa-1.0-x86_64-linux` at `foo`.
90
+ def split_install_dirname(basename)
91
+ platform_split(basename) || plain_split(basename)
92
+ end
93
+
94
+ def platform_split(basename)
95
+ cuts = []
96
+ basename.scan(/-(\d[^-]*)(?=-.)/) { cuts << Regexp.last_match }
97
+ cuts.reverse_each do |match|
98
+ platform = match.post_match.delete_prefix("-")
99
+ # `current` makes `Gem::Platform.new` read the host's platform, which a non-main Ractor may not touch.
100
+ next if platform == "current"
101
+ return [match.pre_match, match[1], platform] unless Gem::Platform.new(platform).os == "unknown"
102
+ end
103
+ nil
104
+ end
105
+
106
+ def plain_split(basename)
80
107
  match = /\A(.+)-(\d[^-]*)\z/.match(basename)
81
- match ? [match[1], match[2]] : nil
108
+ match ? [match[1], match[2], "ruby"] : nil
82
109
  end
83
110
  end
84
111
  end
@@ -30,13 +30,29 @@ module Rigor
30
30
  # dependency that moved from a `git:` source to a released version without a `bundle clean`, so its
31
31
  # git-installed directory is still sitting there under the OLD source's name). Defaults `false` so
32
32
  # existing callers that construct a `LockedGem` without an opinion stay conservative.
33
- LockedGem = Data.define(:name, :version, :platform, :git_source) do
34
- def initialize(name:, version:, platform:, git_source: false)
33
+ #
34
+ # `variants` is every `[version, platform]` pair the lockfile locks under this name, in Bundler's parsed
35
+ # order. A multi-platform lockfile (`bundle lock --add-platform`) lists one spec per platform — `ffi
36
+ # (1.17.4)`, `ffi (1.17.4-arm64-darwin)`, … — and Bundler installs whichever matches the host, so
37
+ # {BundleSigDiscovery} must admit all of them (#1844). `version` / `platform` name one representative
38
+ # variant for the readers that want a single version (coverage report, doctor): the `ruby` one when the
39
+ # lockfile has it, otherwise the last parsed. Defaults to the single `[version, platform]` pair, and the
40
+ # pair must be one of `variants`.
41
+ LockedGem = Data.define(:name, :version, :platform, :git_source, :variants) do
42
+ def initialize(name:, version:, platform:, git_source: false, variants: nil)
43
+ version = -version.to_s
44
+ platform = -platform.to_s
45
+ variants = (variants || [[version, platform]]).map { |v, p| [-v.to_s, -p.to_s].freeze }.uniq.freeze
46
+ unless variants.include?([version, platform])
47
+ raise ArgumentError, "LockedGem #{name}: [#{version}, #{platform}] is not one of its variants"
48
+ end
49
+
35
50
  super(
36
51
  name: -name.to_s,
37
- version: -version.to_s,
38
- platform: -platform.to_s,
39
- git_source: git_source ? true : false
52
+ version: version,
53
+ platform: platform,
54
+ git_source: git_source ? true : false,
55
+ variants: variants
40
56
  )
41
57
  end
42
58
  end
@@ -162,10 +178,13 @@ module Rigor
162
178
  # `#source` is the `Bundler::Source::*` this spec resolved from — `Git` for a `GIT` lockfile
163
179
  # stanza, `Rubygems` for a `GEM` one, `Path`/`Gemspec` for `path:`. Only `Git` sources land under
164
180
  # {BundleSigDiscovery}'s `bundler/gems/` walk.
165
- h[spec.name.to_s] = LockedGem.new(
181
+ # A multi-platform lockfile repeats the name once per platform; fold the repeats into `variants`
182
+ # instead of letting the last one overwrite the rest (#1844).
183
+ entry = LockedGem.new(
166
184
  name: spec.name, version: spec.version.to_s, platform: platform,
167
185
  git_source: spec.source.is_a?(Bundler::Source::Git)
168
186
  )
187
+ h[entry.name] = (prior = h[entry.name]) ? merge_variant(prior, entry) : entry
169
188
  end
170
189
  locked.freeze
171
190
  rescue ::Ractor::IsolationError => e
@@ -177,6 +196,18 @@ module Rigor
177
196
  end
178
197
  private_class_method :do_parse
179
198
 
199
+ # Folds a further platform variant of an already-parsed name into it. The representative keeps a `ruby`
200
+ # variant once seen; otherwise the last variant wins, as it did before variants were kept.
201
+ def self.merge_variant(prior, entry)
202
+ rep = prior.platform == "ruby" ? prior : entry
203
+ prior.with(
204
+ variants: [*prior.variants, [entry.version, entry.platform]],
205
+ version: rep.version, platform: rep.platform,
206
+ git_source: prior.git_source || entry.git_source
207
+ )
208
+ end
209
+ private_class_method :merge_variant
210
+
180
211
  # #1064 — Bundler's lockfile parser reads RubyGems module state a non-main Ractor may not touch, so the
181
212
  # parse raises `Ractor::IsolationError` there whatever the lockfile says. That is an engine constraint, not
182
213
  # a malformed lockfile, and the warning must not send the user to fix a file that is fine. The Ractor pool
@@ -37,8 +37,8 @@ module Rigor
37
37
  module MissingGemConstantIndex
38
38
  module_function
39
39
 
40
- # @param gems — `[gem_name, version]` pairs (the `:missing` rows of
41
- # {RbsCoverageReport}).
40
+ # @param gems — `[gem_name, version, variants]` entries (the `:missing` rows of {RbsCoverageReport},
41
+ # with the gem's `LockedGem#variants`); `variants` may be omitted or nil, meaning the `ruby` one.
42
42
  # @param bundle_path — the target's resolved bundler install root, or nil.
43
43
  # @param spec_resolver — `(name, version) -> String?` fallback dir resolver. Injectable for
44
44
  # specs; the default is the RubyGems-metadata lookup (no code load).
@@ -49,8 +49,9 @@ module Rigor
49
49
  bundle_dirs = bundle_gem_dirs(bundle_path)
50
50
  git_dirs = git_gem_dirs(bundle_path)
51
51
  index = {}
52
- gems.each do |gem_name, version|
53
- dir = bundle_dirs["#{gem_name}-#{version}"] || git_dirs[gem_name] || spec_resolver.call(gem_name, version)
52
+ gems.each do |gem_name, version, variants|
53
+ dir = bundle_dir(bundle_dirs, gem_name, version, variants) ||
54
+ git_dirs[gem_name] || spec_resolver.call(gem_name, version)
54
55
  next unless dir
55
56
 
56
57
  entry_files(dir, gem_name).each do |file|
@@ -62,9 +63,8 @@ module Rigor
62
63
 
63
64
  # `{"<name>-<version>" => gem_dir}` for the target's bundle, or `{}` when no bundle is resolvable. The
64
65
  # glob covers the RUBYGEMS-sourced layout only, `<bundle>/ruby/X.Y.Z/gems/<name>-<version>/`; the
65
- # git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename so a platform-tagged variant
66
- # (`ffi-1.17.4-aarch64-linux-gnu`) simply doesn't match a `<name>-<version>` lookup — those gems ship
67
- # native code, not the pure-Ruby constants this indexes.
66
+ # git-sourced layout is {.git_gem_dirs}'s. Keyed on the dir basename, which {.bundle_dir} reconstructs
67
+ # per locked variant.
68
68
  def bundle_gem_dirs(bundle_path)
69
69
  return {} if bundle_path.nil?
70
70
 
@@ -76,6 +76,21 @@ module Rigor
76
76
  end
77
77
  end
78
78
 
79
+ # The installed directory of one of the gem's locked variants: `<name>-<version>` for the `ruby` one,
80
+ # `<name>-<version>-<platform>` for a platform-tagged one. Bundler installs only the variant matching
81
+ # the host, and a multi-platform lockfile's representative is usually the `ruby` one, so every variant
82
+ # is tried. A platform-tagged gem is not a native-only gem: the commonmarker, nokogiri and sqlite3 builds
83
+ # each ship `lib/<name>.rb` declaring the gem's top-level namespace, exactly as the `ruby` build does.
84
+ def bundle_dir(bundle_dirs, gem_name, version, variants)
85
+ [[version, "ruby"], *variants].each do |variant_version, platform|
86
+ basename = "#{gem_name}-#{variant_version}"
87
+ basename = "#{basename}-#{platform}" unless platform == "ruby"
88
+ dir = bundle_dirs[basename]
89
+ return dir if dir
90
+ end
91
+ nil
92
+ end
93
+
79
94
  # Issue #763 — `{gem_name => gem_dir}` for the target's GIT-sourced gems,
80
95
  # `<bundle>/ruby/X.Y.Z/bundler/gems/<repo>-<12-hex-revision>/`. Before this, a `git:`-sourced gem was
81
96
  # invisible to the index, so every constant reaching into it recorded the generic engine-gap cause
@@ -32,7 +32,7 @@ module Rigor
32
32
  # hard-coding is acceptable; a directory walk at every call would add stat-cost to no benefit.)
33
33
  VENDORED_GEM_NAMES = Set[
34
34
  "ast", "bcrypt", "bundler", "cgi", "did_you_mean",
35
- "idn-ruby", "mysql2", "nokogiri", "pg", "prism", "racc", "redis", "rubygems"
35
+ "idn-ruby", "mysql2", "nokogiri", "pg", "prime", "prism", "racc", "redis", "rubygems"
36
36
  ].freeze
37
37
 
38
38
  # @param locked_gems — The lockfile-resolved gem set. Empty