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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot"
4
+ require_relative "parameters"
5
+
6
+ module Rigor
7
+ module Lens
8
+ # Groups the sites of the requested files into members and fills their signatures (docs/internal-spec/lens.md
9
+ # § Members). A member is one `(owner, scope, Ruby method name)`; an `attr_accessor :a` site defines `a` and
10
+ # `a=`. An inferred slot comes from a site's body only when the member has exactly one project definition.
11
+ class Members
12
+ # @param sites — every {FileReader::Site} of the requested files
13
+ # @param omissions — every omission of the requested files
14
+ # @param contracts — `{[owner, scope, name] => ContractReader::Contract or nil}`
15
+ # @param elsewhere — callable `(owner, scope, name) -> [{path, line}]`: other project files defining it
16
+ # @param site_paths — `{site key => absolute path of its file}`, to match an inline contract's buffer
17
+ def initialize(sites:, omissions:, contracts:, elsewhere:, site_paths:)
18
+ @sites = sites
19
+ @omissions = omissions
20
+ @contracts = contracts
21
+ @elsewhere = elsewhere
22
+ @site_paths = site_paths
23
+ end
24
+
25
+ def build
26
+ groups = Hash.new { |hash, key| hash[key] = [] }
27
+ @sites.each do |site|
28
+ site.data["members"].each { |name| groups[[site.data["owner"], site.data["scope"], name]] << site }
29
+ end
30
+ result = groups.sort_by { |key, _| key }.map { |key, sites| member(key, sites) }
31
+ mark_inline_sites
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ def member(key, sites)
38
+ owner, scope, name = key
39
+ contract = @contracts[key]
40
+ also = @elsewhere.call(owner, scope, name)
41
+ omitted = @omissions.count { |omission| [omission["owner"], omission["scope"], omission["name"]] == key }
42
+ single = sites.size == 1 && omitted.zero? && also.empty? ? sites.first : nil
43
+ { "owner" => owner, "scope" => scope, "name" => name, "kind" => member_kind(sites),
44
+ "visibility" => common_visibility(sites), "sites" => sites.map { |site| site.data["key"] },
45
+ "also_defined_at" => also, "basis" => basis(contract, single),
46
+ "overloads" => overloads(contract, sites.first, name, single) }
47
+ end
48
+
49
+ # A member a `def` defines anywhere in the requested files is a method, whatever `attr_*` also names it.
50
+ def member_kind(sites)
51
+ sites.any? { |site| site.data["kind"] == "method" } ? "method" : "attribute"
52
+ end
53
+
54
+ def basis(contract, single)
55
+ return "contract" if contract
56
+ return "single_site" if single
57
+
58
+ "contested"
59
+ end
60
+
61
+ def common_visibility(sites)
62
+ values = sites.map { |site| site.data["visibility"] }.uniq
63
+ values.size == 1 ? values.first : nil
64
+ end
65
+
66
+ # A contested member without a contract has no overloads: its definitions may differ in arity, and each
67
+ # method site carries its own parameter list.
68
+ def overloads(contract, site, name, single)
69
+ return [] if contract.nil? && single.nil?
70
+
71
+ overloads = contract ? contract_overloads(contract, single) : [body_overload(site, name, single)]
72
+ overloads.map { |overload| overload.merge("rbs" => rbs_unless_unavailable(overload)) }
73
+ end
74
+
75
+ # A contract's overloads. A defaulted return (`inferred-return`) is inferred as for a member without a
76
+ # contract, and its `untyped` parameters are unknown: Rigor cannot tell an authored `untyped` from a
77
+ # defaulted one.
78
+ def contract_overloads(contract, single)
79
+ contract.overloads.map do |overload|
80
+ next overload.except("return_rbs") unless contract.return_inferred
81
+
82
+ slot = inferred_return(single)
83
+ params = overload["params"]&.map do |param|
84
+ param["type"]["type"] == "untyped" ? param.merge("type" => Slot.unavailable("not_computed")) : param
85
+ end
86
+ rbs = overload["rbs"]
87
+ suffix = overload["return_rbs"]
88
+ rbs = rbs.delete_suffix(suffix) + return_spelling(slot) if rbs && suffix && rbs.end_with?(suffix)
89
+ overload.except("return_rbs").merge("rbs" => rbs, "params" => params, "return" => slot)
90
+ end
91
+ end
92
+
93
+ # A member with no contract: its parameters as written, each `untyped` with ADR-75's undeclared-parameter
94
+ # cause, and the return from its one body.
95
+ def body_overload(site, name, single)
96
+ return attribute_overload(name, single) if site.data["kind"] == "attribute"
97
+
98
+ params, block, head = Parameters.read(site.node.parameters, yields: Parameters.yields?(site.node.body))
99
+ slot = inferred_return(single)
100
+ block_rbs = block ? " ?{ (?) -> untyped }" : ""
101
+ { "rbs" => "#{head}#{block_rbs} -> #{return_spelling(slot)}", "params" => params, "block" => block,
102
+ "return" => slot }
103
+ end
104
+
105
+ # An attribute without a contract: Rigor does not report the ivar's type in revision 1.
106
+ def attribute_overload(name, _single)
107
+ unknown = Slot.unavailable("not_computed")
108
+ if name.end_with?("=")
109
+ param = { "name" => nil, "kind" => "required", "type" => Slot.unavailable("not_computed") }
110
+ { "rbs" => nil, "params" => [param], "block" => nil, "return" => unknown }
111
+ else
112
+ { "rbs" => nil, "params" => [], "block" => nil, "return" => unknown }
113
+ end
114
+ end
115
+
116
+ def inferred_return(single)
117
+ return Slot.unavailable("contested") unless single
118
+
119
+ body = single.data["body_return"]
120
+ return Slot.unavailable("not_computed") if body.nil?
121
+ return body if Slot.unavailable?(body)
122
+
123
+ body.merge("from_site" => single.data["key"])
124
+ end
125
+
126
+ def return_spelling(slot)
127
+ spelled = Slot.rbs_spelling(slot)
128
+ spelled.include?(" | ") ? "(#{spelled})" : spelled
129
+ end
130
+
131
+ # `rbs` is null when any slot of the overload is unavailable: an `untyped` there would read as an assertion.
132
+ def rbs_unless_unavailable(overload)
133
+ slots = [overload["return"], overload.dig("block", "type")] +
134
+ (overload["params"] || []).map { |param| param["type"] }
135
+ slots.any? { |slot| Slot.unavailable?(slot) } ? nil : overload["rbs"]
136
+ end
137
+
138
+ # A site reads `contract: inline` when its own contract comments produced an inline declaration of a member
139
+ # it defines.
140
+ def mark_inline_sites
141
+ @sites.each do |site|
142
+ path = @site_paths.fetch(site.data["key"])
143
+ asserted = site.contract_comments.any? { |comment| !comment.location.slice.match?(/\A#\s*@extrbs\b/) }
144
+ inline = asserted && site.data["members"].any? do |name|
145
+ contract = @contracts[[site.data["owner"], site.data["scope"], name]]
146
+ contract&.provenance == "inline" && contract.buffers.any? { |buffer| buffer.end_with?(":#{path}") }
147
+ end
148
+ site.data["contract"] = inline ? "inline" : nil
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "slot"
6
+
7
+ module Rigor
8
+ module Lens
9
+ # A `def`'s parameter list as slots and as the RBS head sig-gen would write for an undeclared method.
10
+ module Parameters
11
+ module_function
12
+
13
+ UNDECLARED = "inferred_return_untyped"
14
+
15
+ # [params (nil for an argument-forwarding list), block or nil, "(…)" head]. A definition takes a block when
16
+ # it declares `&blk` or `yields` (its body yields).
17
+ def read(node, yields: false)
18
+ block = yields ? undeclared_block : nil
19
+ return [[], block, "()"] if node.nil?
20
+ return [nil, block, "(?)"] if node.keyword_rest.is_a?(Prism::ForwardingParameterNode)
21
+
22
+ entries = entries(node)
23
+ params = entries.map { |kind, name| { "name" => name&.to_s, "kind" => kind, "type" => undeclared } }
24
+ block = undeclared_block if node.block
25
+ [params, block, "(#{entries.map { |kind, name| spell(kind, name) }.join(', ')})"]
26
+ end
27
+
28
+ # `[kind, name]` per parameter, in declaration order.
29
+ def entries(node)
30
+ node.requireds.map { |param| ["required", name_of(param)] } +
31
+ node.optionals.map { |param| ["optional", param.name] } +
32
+ rest(node.rest) +
33
+ node.posts.map { |param| ["post", name_of(param)] } +
34
+ node.keywords.map { |param| [keyword_kind(param), param.name] } +
35
+ keyword_rest(node.keyword_rest)
36
+ end
37
+
38
+ def undeclared
39
+ Slot.dynamic("inferred", UNDECLARED)
40
+ end
41
+
42
+ def undeclared_block
43
+ { "required" => false, "type" => undeclared }
44
+ end
45
+
46
+ # Whether a `def` body yields, outside the bodies whose `yield` belongs elsewhere: a nested `def`, a lambda,
47
+ # a `define_method` block.
48
+ def yields?(node)
49
+ return false if node.nil?
50
+ return true if node.is_a?(Prism::YieldNode)
51
+ return false if node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode)
52
+ return false if node.is_a?(Prism::CallNode) && node.name == :define_method && node.receiver.nil?
53
+
54
+ node.compact_child_nodes.any? { |child| yields?(child) }
55
+ end
56
+
57
+ # The shape of a parameter list: names and kinds, with every type null (lens.md § Sites).
58
+ def shape(params, block)
59
+ [params&.map { |param| param.merge("type" => nil) }, block&.merge("type" => nil)]
60
+ end
61
+
62
+ def name_of(param)
63
+ param.respond_to?(:name) ? param.name : nil
64
+ end
65
+
66
+ def keyword_kind(param)
67
+ param.is_a?(Prism::RequiredKeywordParameterNode) ? "keyreq" : "key"
68
+ end
69
+
70
+ def rest(node)
71
+ node.is_a?(Prism::RestParameterNode) ? [["rest", node.name]] : []
72
+ end
73
+
74
+ def keyword_rest(node)
75
+ node.is_a?(Prism::KeywordRestParameterNode) ? [["keyrest", node.name]] : []
76
+ end
77
+
78
+ def spell(kind, name)
79
+ label = name ? " #{name}" : ""
80
+ case kind
81
+ when "required", "post" then "untyped#{label}"
82
+ when "optional" then "?untyped#{label}"
83
+ when "rest" then "*untyped#{label}"
84
+ when "keyreq" then "#{name}: untyped"
85
+ when "key" then "?#{name}: untyped"
86
+ when "keyrest" then "**untyped#{label}"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../version"
4
+
5
+ module Rigor
6
+ module Lens
7
+ # `context.plugins` (docs/internal-spec/lens.md § Context): the plugins a run loaded, by manifest id.
8
+ module Plugins
9
+ BUNDLED_PLUGINS = File.expand_path("../../../plugins", __dir__).freeze
10
+
11
+ module_function
12
+
13
+ # `[{id, version}]` by manifest id (the id slot provenance uses). The version is Rigor's for a plugin bundled
14
+ # with it, else the version of the gem whose files define the plugin class, else null. A plugin id is not a gem
15
+ # name: the bundled `rbs-inline` plugin shares its id with the upstream gem.
16
+ def loaded(environment)
17
+ plugins = environment&.plugin_registry&.plugins || []
18
+ rows = plugins.map { |plugin| { "id" => plugin.manifest.id.to_s, "version" => version(plugin) } }
19
+ rows.uniq.sort_by { |plugin| plugin["id"] }
20
+ rescue StandardError
21
+ []
22
+ end
23
+
24
+ def version(plugin)
25
+ file = Object.const_source_location(plugin.class.name)&.first
26
+ return nil if file.nil?
27
+
28
+ absolute = File.expand_path(file)
29
+ return Rigor::VERSION if absolute.start_with?("#{BUNDLED_PLUGINS}/")
30
+
31
+ spec = Gem.loaded_specs.each_value.find do |gem|
32
+ absolute.start_with?("#{gem.full_gem_path}/") # rigor:disable undefined-method
33
+ end
34
+ spec&.version&.to_s
35
+ rescue StandardError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "file_reader"
6
+ require_relative "contract_reader"
7
+ require_relative "members"
8
+ require_relative "plugins"
9
+ require_relative "slot"
10
+ require_relative "../analysis/incremental_session"
11
+ require_relative "../cache/incremental_snapshot"
12
+ require_relative "../cache/store"
13
+ require_relative "../sig_gen/alias_index"
14
+ require_relative "../sig_gen/type_elaborator"
15
+
16
+ module Rigor
17
+ module Lens
18
+ # The check-equivalent computation behind `rigor lens` (docs/internal-spec/lens.md § Computation). The session
19
+ # is the `--incremental` one rooted at the configuration's `paths:`, so it shares the snapshot with
20
+ # `rigor check --incremental`; the targets are forced into the run and read inside it, on the seeded index.
21
+ class Run
22
+ # One target's answer: `status` is `ok` or a failure reason; the rest is set only for `ok`.
23
+ Target = Data.define(:argument, :path, :absolute, :status, :reason, :reader, :diagnostics, :inputs)
24
+ Outcome = Data.define(:targets, :member_rows, :plugins, :warm)
25
+
26
+ # @param root — the project root (absolute); every path in the answer is relative to it
27
+ def initialize(configuration:, root:, arguments:, workers:)
28
+ @configuration = configuration
29
+ @root = root
30
+ @arguments = arguments
31
+ @argument_paths = arguments.to_set { |argument| File.expand_path(argument) }
32
+ @workers = workers
33
+ reset_captures
34
+ @capture_errors = {}
35
+ @plugins = []
36
+ end
37
+
38
+ def call
39
+ session = build_session
40
+ project = session.project_files.to_h { |path| [File.expand_path(path), path] }
41
+ resolved = mark_duplicates(@arguments.map { |argument| resolve(argument, project) })
42
+ forced = resolved.filter_map { |entry| entry[:session_path] }
43
+ session.force_targets = forced.uniq
44
+ diagnostics, warm = analyse(session, forced.uniq)
45
+ targets = resolved.map { |entry| target(entry, diagnostics, session) }
46
+ Outcome.new(targets: targets, member_rows: members(targets, session), plugins: @plugins, warm: warm)
47
+ end
48
+
49
+ private
50
+
51
+ def build_session
52
+ cache_root = @configuration.cache_path
53
+ Analysis::IncrementalSession.new(
54
+ configuration: @configuration,
55
+ cache_store: Cache::Store.new(root: cache_root, max_bytes: @configuration.cache_max_bytes),
56
+ workers: @workers, on_file_indexed: method(:capture)
57
+ )
58
+ end
59
+
60
+ # A warm run is a recheck that forced the targets. A cold one is a baseline: a sequential baseline indexes
61
+ # every target in this process, a pooled one in workers where the callback never runs. The session also
62
+ # falls back to a baseline after a recheck whose seeds it no longer trusts, and the captures that recheck
63
+ # made are stale. So after a pooled baseline the captures are dropped and one forced recheck, on the
64
+ # baseline's fresh state, reads the targets again.
65
+ def analyse(session, forced)
66
+ fingerprint = Cache::IncrementalSnapshot.fingerprint(configuration: @configuration,
67
+ roots: @configuration.paths)
68
+ snapshot = Cache::IncrementalSnapshot.new(root: @configuration.cache_path)
69
+ diagnostics, warm = session.run_incremental(snapshot: snapshot, fingerprint: fingerprint)
70
+ missing = forced.reject { |path| @captured.key?(File.expand_path(path)) }
71
+ if (!warm && @workers.positive?) || !missing.empty?
72
+ reset_captures
73
+ diagnostics = session.recheck.diagnostics
74
+ end
75
+ [diagnostics, warm]
76
+ end
77
+
78
+ def reset_captures
79
+ @captured = {}
80
+ @contracts = {}
81
+ @site_paths = {}
82
+ end
83
+
84
+ # A file requested again, in any spelling, is answered once: the later entries read `duplicate`, naming the
85
+ # argument that carries the answer, so every site key stays unique in the document.
86
+ def mark_duplicates(entries)
87
+ first = {}
88
+ entries.map do |entry|
89
+ next entry unless entry[:status] == "ok"
90
+
91
+ seen = first[entry[:absolute]]
92
+ first[entry[:absolute]] ||= entry[:argument]
93
+ next entry unless seen
94
+
95
+ entry.merge(status: "duplicate", reason: "the same file as #{seen}", session_path: nil)
96
+ end
97
+ end
98
+
99
+ def resolve(argument, project)
100
+ absolute = File.expand_path(argument)
101
+ entry = { argument: argument, absolute: absolute, path: relative(absolute) }
102
+ return entry.merge(status: "not_found", reason: "no such file") unless File.file?(absolute)
103
+ return entry.merge(status: "not_ruby", reason: "not a Ruby source file") unless absolute.end_with?(".rb")
104
+
105
+ session_path = project[absolute]
106
+ return entry.merge(status: "outside_project", reason: "not under the configured paths:") unless session_path
107
+
108
+ entry.merge(status: "ok", session_path: session_path)
109
+ end
110
+
111
+ # The runner's index callback. An error here never reaches the run: it is recorded, and the target reads
112
+ # `failed` with the reason.
113
+ def capture(path, parse_result, scope, index)
114
+ absolute = File.expand_path(path)
115
+ return unless @argument_paths.include?(absolute)
116
+
117
+ @plugins = Plugins.loaded(scope.environment) if @plugins.empty?
118
+ read_target(absolute, parse_result, scope, index)
119
+ @capture_errors.delete(absolute)
120
+ rescue StandardError => e
121
+ @captured.delete(absolute)
122
+ @capture_errors[absolute] = "#{e.class}: #{e.message}"
123
+ end
124
+
125
+ def read_target(absolute, parse_result, scope, index)
126
+ reader = FileReader.new(path: relative(absolute), parse_result: parse_result, index: index,
127
+ spell: speller(scope.environment)).read
128
+ reader.sites.each do |site|
129
+ @site_paths[site.data["key"]] = absolute
130
+ site.data["members"].each { |name| read_contract(site.data["owner"], site.data["scope"], name, scope) }
131
+ end
132
+ @captured[absolute] = reader
133
+ end
134
+
135
+ def read_contract(owner, member_scope, name, scope)
136
+ key = [owner, member_scope, name]
137
+ return if @contracts.key?(key)
138
+
139
+ @contracts[key] = contract_reader(scope.environment).read(owner, member_scope.to_sym, name, scope)
140
+ end
141
+
142
+ def target(entry, diagnostics, session)
143
+ base = { argument: entry[:argument], path: entry[:path], absolute: entry[:absolute], status: entry[:status],
144
+ reason: entry[:reason], reader: nil, diagnostics: nil, inputs: nil }
145
+ return Target.new(**base) unless entry[:status] == "ok"
146
+
147
+ reader = @captured[entry[:absolute]]
148
+ own = diagnostics.select { |diagnostic| File.expand_path(diagnostic.path.to_s) == entry[:absolute] }
149
+ if reader
150
+ return Target.new(**base, reader: reader, diagnostics: own,
151
+ inputs: inputs(session, entry[:session_path]))
152
+ end
153
+
154
+ Target.new(**base, **uncaptured(entry[:absolute], own))
155
+ end
156
+
157
+ # A forced target the run did not read: one that does not parse stops before indexing; otherwise reading it
158
+ # failed, or the run never indexed it.
159
+ def uncaptured(absolute, own)
160
+ if parse_errors?(absolute)
161
+ { status: "parse_error", reason: "the file does not parse", diagnostics: own }
162
+ else
163
+ { status: "failed", reason: @capture_errors.fetch(absolute, "the file was not analysed") }
164
+ end
165
+ rescue StandardError => e
166
+ # The parse itself failed (an unsupported `target_ruby`, say): the run's own parse did too.
167
+ { status: "failed", reason: "#{e.class}: #{e.message}" }
168
+ end
169
+
170
+ def parse_errors?(absolute)
171
+ Prism.parse_file(absolute, version: @configuration.target_ruby).errors.any?
172
+ end
173
+
174
+ def inputs(session, session_path)
175
+ rows = session.dependency_sources(session_path).filter_map do |source|
176
+ absolute = File.expand_path(source)
177
+ next if absolute == File.expand_path(session_path) || !File.file?(absolute)
178
+
179
+ { "path" => relative(absolute), "digest" => Lens.digest(File.binread(absolute)) }
180
+ end
181
+ rows.sort_by { |input| input["path"] }
182
+ end
183
+
184
+ def members(targets, session)
185
+ # A file requested twice, in any spelling, contributes its declarations once.
186
+ readers = targets.filter_map { |target| target.reader && [target.absolute, target.reader] }.to_h
187
+ sites = readers.values.flat_map(&:sites)
188
+ omissions = readers.values.flat_map(&:omissions)
189
+ requested = readers.keys.to_set
190
+ Members.new(sites: sites, omissions: omissions, contracts: @contracts,
191
+ elsewhere: elsewhere(session, requested), site_paths: @site_paths).build
192
+ end
193
+
194
+ # Other project files that define a member, from the per-file discovery bundles the session keeps. The bundle
195
+ # paths are resolved once; each member then costs one lookup per bundle.
196
+ def elsewhere(session, requested)
197
+ bundles = session.seed_bundles.filter_map do |path, bundle|
198
+ absolute = File.expand_path(path)
199
+ [relative(absolute), bundle] unless requested.include?(absolute)
200
+ end
201
+ lambda do |owner, scope, name|
202
+ entries = bundles.filter_map do |path, bundle|
203
+ line = defined_line(bundle, owner, scope, name)
204
+ line == false ? nil : { "path" => path, "line" => line }
205
+ end
206
+ entries.sort_by { |entry| entry["path"] }
207
+ end
208
+ end
209
+
210
+ # The line a bundle records for the member, nil when it defines the member without a recorded line (an
211
+ # `attr_*`), or false when it does not define it.
212
+ # A top-level `def` is recorded under discovery's own key, apart from `class Object`; both count for
213
+ # `Object#name`.
214
+ def defined_line(bundle, owner, scope, name)
215
+ owners = if owner == "Object" && scope == "instance"
216
+ [owner,
217
+ Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY]
218
+ else
219
+ [owner]
220
+ end
221
+ owners.each do |key|
222
+ line = defined_line_for(bundle, key, scope, name)
223
+ return line unless line == false
224
+ end
225
+ false
226
+ end
227
+
228
+ def defined_line_for(bundle, owner, scope, name)
229
+ table = scope == "singleton" ? bundle[:singleton_def_sources] : bundle[:def_sources]
230
+ source = table&.dig(owner, name.to_sym)
231
+ return Integer(source.to_s.split(":").last) if source
232
+
233
+ kinds = bundle[:methods]&.dig(owner, name.to_sym)
234
+ return false if kinds.nil?
235
+ return nil if kinds == :both || kinds.to_s == scope
236
+
237
+ false
238
+ end
239
+
240
+ def speller(environment)
241
+ @alias_index ||= SigGen::AliasIndex.build(environment: environment, signature_paths: signature_roots,
242
+ excluded_paths: plugin_signature_paths(environment))
243
+ lambda do |type, owner|
244
+ @alias_index.fold(SigGen::TypeElaborator.elaborate(type, environment: environment).erase_to_rbs, owner)
245
+ end
246
+ end
247
+
248
+ def contract_reader(environment)
249
+ @contract_reader ||= ContractReader.new(
250
+ Provenance.new(root: @root, signature_roots: signature_roots.map { |path| File.expand_path(path.to_s) },
251
+ plugin_roots: plugin_roots(environment))
252
+ )
253
+ end
254
+
255
+ # `{absolute signature directory => plugin id}` for every loaded plugin that ships signatures.
256
+ def plugin_roots(environment)
257
+ plugins = environment&.plugin_registry&.plugins || []
258
+ plugins.each_with_object({}) do |plugin, roots|
259
+ Array(plugin.signature_paths).each { |path| roots[File.expand_path(path.to_s)] = plugin.manifest.id }
260
+ end
261
+ rescue StandardError
262
+ {}
263
+ end
264
+
265
+ def signature_roots
266
+ @signature_roots ||= Environment.project_signature_roots(@configuration.signature_paths)
267
+ end
268
+
269
+ def plugin_signature_paths(environment)
270
+ environment&.plugin_registry&.signature_paths || []
271
+ rescue StandardError
272
+ []
273
+ end
274
+
275
+ def relative(absolute)
276
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Lens
5
+ # Tagged type slots and RBS-buffer provenance (docs/internal-spec/lens.md § Slots).
6
+ module Slot
7
+ module_function
8
+
9
+ def typed(type, provenance, path: nil, from_site: nil)
10
+ { "state" => "typed", "type" => type, "provenance" => provenance, "path" => path, "from_site" => from_site }
11
+ end
12
+
13
+ def dynamic(provenance, cause, path: nil, from_site: nil)
14
+ { "state" => "dynamic", "type" => "untyped", "provenance" => provenance, "path" => path,
15
+ "from_site" => from_site, "cause" => cause&.to_s }
16
+ end
17
+
18
+ def unavailable(reason)
19
+ { "state" => "unavailable", "reason" => reason }
20
+ end
21
+
22
+ def unavailable?(slot)
23
+ slot.is_a?(Hash) && slot["state"] == "unavailable"
24
+ end
25
+
26
+ # A slot for an RBS type string read from a contract. `untyped` is a dynamic slot whose cause is the
27
+ # authored contract.
28
+ def from_rbs(type, provenance, path)
29
+ return dynamic(provenance, :explicit_untyped, path: path) if type == "untyped"
30
+
31
+ typed(type, provenance, path: path)
32
+ end
33
+
34
+ # The RBS type a slot puts in an overload's `rbs` string: an unavailable slot reads `untyped`.
35
+ def rbs_spelling(slot)
36
+ slot["state"] == "typed" ? slot["type"] : "untyped"
37
+ end
38
+ end
39
+
40
+ # Maps an RBS member's buffer name to a provenance (§ Slots).
41
+ class Provenance
42
+ CACHED_BUFFER = "<cached>"
43
+
44
+ # @param root — the project root (absolute)
45
+ # @param signature_roots — the project signature roots (absolute)
46
+ # @param plugin_roots — `{absolute signature directory => plugin id}` of the loaded plugins
47
+ def initialize(root:, signature_roots:, plugin_roots:)
48
+ @root = root
49
+ @signature_roots = signature_roots
50
+ @plugin_roots = plugin_roots
51
+ end
52
+
53
+ # [provenance, path] for a buffer name, or nil when the buffer cannot be classified.
54
+ def classify(buffer_name)
55
+ name = buffer_name.to_s
56
+ return ["inline", nil] if name.start_with?("virtual:rbs-inline:")
57
+ return ["plugin:#{name.split(':', 3)[1]}", nil] if name.start_with?("virtual:")
58
+ return nil if name.empty? || name == CACHED_BUFFER
59
+
60
+ absolute = File.expand_path(name, @root)
61
+ plugin = @plugin_roots.find { |root, _| under?(absolute, [root]) }
62
+ return ["plugin:#{plugin.last}", nil] if plugin
63
+ return ["sig", relative(absolute)] if under?(absolute, @signature_roots)
64
+
65
+ ["library", nil]
66
+ end
67
+
68
+ def relative(absolute)
69
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
70
+ end
71
+
72
+ private
73
+
74
+ def under?(absolute, roots)
75
+ roots.any? { |root| absolute == root || absolute.start_with?("#{root}/") }
76
+ end
77
+ end
78
+ end
79
+ end
data/lib/rigor/lens.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hashing/xxh3"
4
+
5
+ module Rigor
6
+ # `rigor lens`: a declaration map of the requested files with type provenance, computed as `rigor check` computes
7
+ # them (ADR-113; the output contract is docs/internal-spec/lens.md).
8
+ module Lens
9
+ SCHEMA = "lens/v0"
10
+ # Bumped on every breaking change to the `lens/v0` document (docs/internal-spec/lens.md § Status).
11
+ REVISION = 1
12
+ CAPABILITIES = %w[class module method attribute body_return diagnostics inputs].freeze
13
+
14
+ module_function
15
+
16
+ # A Prism location as the document's span object: 1-based lines, 0-based byte columns and offsets.
17
+ def span(location)
18
+ { "start_line" => location.start_line, "start_column" => location.start_column,
19
+ "end_line" => location.end_line, "end_column" => location.end_column,
20
+ "start_offset" => location.start_offset, "end_offset" => location.end_offset }
21
+ end
22
+
23
+ # The span from the first to the last of a site's contract comments, or nil when it has none.
24
+ def contract_span(comments)
25
+ return nil if comments.empty?
26
+
27
+ first = comments.first.location
28
+ last = comments.last.location
29
+ { "start_line" => first.start_line, "start_column" => first.start_column, "end_line" => last.end_line,
30
+ "end_column" => last.end_column, "start_offset" => first.start_offset, "end_offset" => last.end_offset }
31
+ end
32
+
33
+ def digest(bytes)
34
+ "xxh3-64:#{Hashing::XXH3.file_hash(bytes)}"
35
+ end
36
+ end
37
+ end
38
+
39
+ require_relative "lens/run"
40
+ require_relative "lens/document"