rigortype 0.4.2 → 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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -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"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "environment"
4
+ require_relative "inference/project_patched_scanner"
4
5
  require_relative "plugin"
5
6
  require_relative "plugin/loader"
6
7
  require_relative "plugin/services"
@@ -55,9 +56,12 @@ module Rigor
55
56
  # `source_rbs_synthesizer` runs over them at env-build time; an empty list contributes no synthesized RBS.
56
57
  # @param feature_sources — path => source text the required-feature scan reads in place of that file (an
57
58
  # editor buffer bound with `--tmp-file` / `--instead-of`).
58
- def build(configuration:, source_files:, feature_sources: {})
59
+ # @param project_patched_methods — the `pre_eval:` registry (see {.project_patched_methods}); nil keeps
60
+ # the unpatched universe.
61
+ def build(configuration:, source_files:, feature_sources: {}, project_patched_methods: nil)
59
62
  features = required_features(configuration, source_files, sources: feature_sources)
60
63
  Environment.for_project(
64
+ project_patched_methods: project_patched_methods,
61
65
  libraries: configuration.libraries,
62
66
  signature_paths: configuration.signature_paths,
63
67
  plugin_registry: load_plugin_registry(configuration),
@@ -69,6 +73,21 @@ module Rigor
69
73
  bare(configuration, features)
70
74
  end
71
75
 
76
+ # The patched-method registry `rigor check` types against for the project's `pre_eval:` files. It depends
77
+ # on those files alone, so it is built by the same {Inference::ProjectPatchedScanner} the Runner's project
78
+ # pre-pass calls, over the same resolved `configuration.pre_eval` entries (glob-expanded at config load,
79
+ # missing files skipped), without the pre-pass's plugin load / `#prepare` / dependency-source work. An
80
+ # editor `buffer` is honoured, so a `--tmp-file` edit of a pre_eval file is read in flight. Nil when the
81
+ # project lists no `pre_eval:` entry, so a project without one stays byte-identical. Fail-soft.
82
+ def project_patched_methods(configuration, buffer: nil)
83
+ return nil if configuration.pre_eval.empty?
84
+
85
+ existing = configuration.pre_eval.select { |path| File.file?(path) }
86
+ Inference::ProjectPatchedScanner.scan(existing, buffer: buffer).registry
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
72
91
  # Issue #1700 — the {Environment::RequiredFeatures} the environment gates on: the configured paths plus
73
92
  # `extra_files` (the file a probe inspects, which may lie outside them), the same set `rigor check` scans
74
93
  # whichever files it is given, so a position types as `rigor check` types it.
@@ -84,7 +84,8 @@ module Rigor
84
84
 
85
85
  # Human-readable reasons the caller prints. Each names something the operator can act on.
86
86
  NO_SNAPSHOT = "no reusable `rigor check --incremental` snapshot"
87
- OPAQUE_PLUGIN = "a plugin contributes types with no incremental fingerprint surface"
87
+ OPAQUE_PLUGIN = "a plugin is opaque to the incremental fingerprint " \
88
+ "(no fingerprint surface, or a value it cannot digest)"
88
89
  UNDIGESTIBLE = "the project-scan tables could not be digested"
89
90
  UNIDENTIFIED_ENGINE = "the engine's own source tree could not be digested"
90
91
 
data/lib/rigor/scope.rb CHANGED
@@ -251,11 +251,11 @@ module Rigor
251
251
  # class in a project that publishes nothing) allocates none.
252
252
  EMPTY_PUBLISHED_CONSTANT_IVARS = Set.new.freeze
253
253
  # The index `||=` sites of a repeating block body whose slot an earlier run of the body may have filled,
254
- # keyed by node identity and laid by a block-return pass at the body's entry
255
- # ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which). The memoizing `||=` reading
256
- # (`StatementEvaluator#index_compound_write_value`) is withheld at such a site. The mark belongs to the
257
- # site, so no rebind or narrowing of a variable drops it, and a join keeps a site either arm holds: the
258
- # mark only ever withholds that reading, whose answer is the narrower one.
254
+ # keyed by node identity and laid at the body's entry by a block-return pass or by `StatementEvaluator`'s
255
+ # statement and write-back passes ({#with_repeated_or_writes}; `Inference::RepeatedOrWrites` decides which).
256
+ # The memoizing `||=` reading (`StatementEvaluator#index_compound_write_value`) is withheld at such a site.
257
+ # The mark belongs to the site, so no rebind or narrowing of a variable drops it, and a join keeps a site
258
+ # either arm holds: the mark only ever withholds that reading, whose answer is the narrower one.
259
259
  EMPTY_REPEATED_OR_WRITES = {}.compare_by_identity.freeze
260
260
  # Issue #1429 — the narrowing a guard leaves on a constant reference (`STDOUT.is_a?(StringIO)`, `if CONFIG`),
261
261
  # keyed by the reference's spelling ({Inference::Narrowing.constant_key}: `"STDOUT"`, `"::Foo::BAR"`). A constant
@@ -457,6 +457,13 @@ module Rigor
457
457
  @locals[name.to_sym]
458
458
  end
459
459
 
460
+ # Issue #1840 — replaces the joined bindings of locals a branch merge only narrowed with their pre-branch
461
+ # binding ({Inference::UntypedRejoin}). Unlike {#with_local} this is not a write: the value is the one the
462
+ # branches started from, so every fact, narrowing and mark the join kept about it still holds.
463
+ def with_rejoined_locals(bindings)
464
+ rebuild(locals: @locals.merge(bindings).freeze)
465
+ end
466
+
460
467
  def with_local(name, type)
461
468
  bind_local(name, type, keep_marks: false)
462
469
  end
@@ -1055,20 +1062,22 @@ module Rigor
1055
1062
  @repeated_or_writes.key?(node)
1056
1063
  end
1057
1064
 
1058
- # Regex match-data globals (`$~`, `$&`, `$1..$9`, the pre/post-match and last-paren back-references). Narrowed
1065
+ # Regex match-data globals (`$~`, `$&`, `$1..$N`, the pre/post-match and last-paren back-references). Narrowed
1059
1066
  # on a successful-`=~` / `case`-`when` match edge (see `Narrowing#regex_match_predicate_scopes`). They live in
1060
1067
  # the method frame's special-variable slot, which the method's blocks and closures share (issue #1358), so a
1061
1068
  # later call that may run a match in this frame — a match-capable call, a block that may match, a call once
1062
1069
  # the frame has made a closure that may — rebinds every one of them, and `eval_call` forgets the narrowed
1063
1070
  # facts here. Always safe — only drops facts, so a subsequent read falls back to the default `String | nil`.
1064
1071
  # Program-level `$GLOBAL = ...` seeds use other names and are untouched.
1065
- MATCH_DATA_GLOBALS = %i[$~ $& $` $' $+ $1 $2 $3 $4 $5 $6 $7 $8 $9].freeze
1066
- private_constant :MATCH_DATA_GLOBALS
1072
+ # Issue #1384 — the numbered groups are matched by name, not listed: a regex may carry more than nine captures, and
1073
+ # `$10` and above are the same slot as `$1`..`$9`, so forgetting must clear them too.
1074
+ MATCH_DATA_GLOBAL_NAME = /\A\$(?:~|&|`|'|\+|[1-9]\d*)\z/
1075
+ private_constant :MATCH_DATA_GLOBAL_NAME
1067
1076
 
1068
1077
  def forget_match_globals
1069
1078
  return self unless match_globals_bound?
1070
1079
 
1071
- rebuild(globals: @globals.except(*MATCH_DATA_GLOBALS).freeze)
1080
+ rebuild(globals: @globals.reject { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }.freeze)
1072
1081
  end
1073
1082
 
1074
1083
  # Issue #1361 — this scope with every bound match-data global rebound to `Dynamic[top]`, and an unbound one left
@@ -1079,14 +1088,16 @@ module Rigor
1079
1088
  return self unless match_globals_bound?
1080
1089
 
1081
1090
  untyped = Type::Combinator.untyped
1082
- rebound = MATCH_DATA_GLOBALS.each_with_object({}) { |name, acc| acc[name] = untyped if @globals.key?(name) }
1091
+ rebound = @globals.each_with_object({}) do |(name, _), acc|
1092
+ acc[name] = untyped if MATCH_DATA_GLOBAL_NAME.match?(name.name)
1093
+ end
1083
1094
  rebuild(globals: @globals.merge(rebound).freeze)
1084
1095
  end
1085
1096
 
1086
1097
  # True when any match-data global holds a binding, which a match edge or {#untyped_match_globals} makes: the only
1087
1098
  # state {#forget_match_globals} can drop, and so the gate on every scan that decides whether to.
1088
1099
  def match_globals_bound?
1089
- !@globals.empty? && MATCH_DATA_GLOBALS.any? { |name| @globals.key?(name) }
1100
+ !@globals.empty? && @globals.any? { |name, _| MATCH_DATA_GLOBAL_NAME.match?(name.name) }
1090
1101
  end
1091
1102
 
1092
1103
  # Issue #1359 — the last line read, `$_`, lives in the same frame slot as the match globals, and so on the same
@@ -57,7 +57,8 @@ module Rigor
57
57
  # a parameter-only inline `# @rbs` annotation) is the ABSENCE of a return statement, not the author's
58
58
  # word about it — the opposite of `void`. It is treated like no declaration at all: the inferred return
59
59
  # is proposed as `tighter-return`, carrying `untyped` as the declared spelling so `--diff` still shows a
60
- # declaration existed (#995; see {#declared_untyped?}).
60
+ # declaration existed (#995; see {#declared_untyped?}). A value literal the body proves (Integer, String,
61
+ # Symbol) is widened to its class there unless the `untyped` is only the inline reader's synthesis (#1489).
61
62
  # - A `tighter-return` over the class's own declaration replaces the return and nothing else: the rest of
62
63
  # the line is the declaration's, spelled as written, so `--overwrite` never changes which calls the
63
64
  # signature accepts. A declaration with more than one overload, or one spelled as an `alias`, is left
@@ -1416,14 +1417,53 @@ module Rigor
1416
1417
  declared.is_a?(Type::Dynamic)
1417
1418
  end
1418
1419
 
1419
- # Proposed without {#compare_against_declared}'s tightening guards: a declared `untyped` return carries no
1420
- # information to weigh {#tighter?} or {#literal_decline?} against, the same position a method with no
1421
- # declaration at all is in. The line is built as for any tightening ({#tighter_return_line}), and
1422
- # `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag says one existed.
1420
+ # Proposed without {#tighter?}: a declared `untyped` return carries no information to weigh it against, the
1421
+ # same position a method with no declaration at all is in. Over a declaration the author wrote, though, a
1422
+ # VALUE literal the body proves (`-> 1`, `-> "x"`, `-> :k`) is an implementation detail the `untyped` was
1423
+ # abstracting over, so it is widened to its class (`1 | 2` to `Integer`, `"S" | nil` to `(String | nil)`; #1489).
1424
+ # `nil`, `true` and `false` are honest types and stay, as does a computed `Float`. An `untyped` that is only
1425
+ # the inline reader's synthesis ({#synthesized_declaration?}) is no word of the author's and keeps the
1426
+ # strictest carrier. `declared_return_rbs` carries `"untyped"` so the `[tighter, was: untyped]` print tag
1427
+ # says a declaration existed.
1423
1428
  def declared_untyped_candidate(path, def_node, class_name, kind, inferred, method_def, rendered: nil)
1429
+ unless synthesized_declaration?(method_def)
1430
+ widened = widen_value_literals(inferred)
1431
+ unless widened.equal?(inferred)
1432
+ # A `rendered` line was spelled from the unwidened type; there is nothing to rebuild it from.
1433
+ return equivalent(path, def_node, class_name, kind, inferred, "untyped") if rendered
1434
+
1435
+ inferred = widened
1436
+ end
1437
+ end
1438
+
1424
1439
  tighter_return_candidate(path, def_node, class_name, kind, inferred, method_def, "untyped", rendered)
1425
1440
  end
1426
1441
 
1442
+ VALUE_LITERAL_CLASSES = [Integer, String, Symbol].freeze
1443
+ private_constant :VALUE_LITERAL_CLASSES
1444
+
1445
+ # The same object back when nothing needed widening.
1446
+ def widen_value_literals(type)
1447
+ case type
1448
+ when Type::Constant
1449
+ return type unless VALUE_LITERAL_CLASSES.any? { |klass| type.value.instance_of?(klass) }
1450
+
1451
+ Type::Combinator.nominal_of(type.value.class.name)
1452
+ when Type::Union
1453
+ members = type.members.map { |member| widen_value_literals(member) }
1454
+ members.each_with_index.all? { |m, i| m.equal?(type.members[i]) } ? type : Type::Combinator.union(*members)
1455
+ else
1456
+ type
1457
+ end
1458
+ end
1459
+
1460
+ # A declaration only the inline reader's synthesis (a `virtual:` buffer) supplies — ADR-93 gives every `def` an
1461
+ # `untyped` member, annotated or not — is no word of the author's about the return, so it keeps the proposal
1462
+ # a method with no declaration at all gets: the strictest carrier the body proves.
1463
+ def synthesized_declaration?(method_def)
1464
+ method_def.defs.all? { |type_def| type_def.member.location&.buffer&.name.to_s.start_with?("virtual:") }
1465
+ end
1466
+
1427
1467
  def build_declared_return(method_def)
1428
1468
  translated = method_def.method_types.filter_map { |mt| translate_method_type_return(mt) }
1429
1469
  return nil if translated.empty?
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.4.2"
4
+ VERSION = "0.4.3"
5
5
  end