rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+ require_relative "../source/node_walker"
5
+ require_relative "captured_locals"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Issue #1840 — a branch merge that re-joins the narrowed edges of an untyped local gives it back its
10
+ # untyped binding.
11
+ #
12
+ # Flow narrowing concretizes a `Dynamic[top]` receiver (ADR-83): `x.nil?` splits it into `nil` and
13
+ # `Dynamic[top]`, and `when String` into `String` and `Dynamic[top]`. `Scope#join` unions the edges, so
14
+ # after both merges the local reads `Dynamic[top] | String | nil`. That union is not wrong as a set, but
15
+ # every member is a narrowing of the one untyped value, so it says nothing the pre-branch binding did
16
+ # not. It does, however, hold `nil` beside a nameable class, and `call.possible-nil-receiver` reports
17
+ # `x + "a"` on correct code although neither narrowing alone did.
18
+ #
19
+ # The rule: at a merge, a local whose pre-branch binding is exactly `Dynamic[top]`, that no merged arm
20
+ # writes, and whose joined binding is a union still carrying a `Dynamic` member, is bound to
21
+ # `Dynamic[top]` again.
22
+ #
23
+ # - The write test keeps a nil the program puts there: `x = nil if c` writes `x`, so the join stays
24
+ # `Dynamic[top] | nil` and still reports. It is syntactic, over every local-write form under the arms
25
+ # (a pattern capture and a block's write to the outer local included), so a doubtful arm keeps today's
26
+ # join. It does not see a written nil that a `nil?` edge stripped before the merge (`x = nil if c; if
27
+ # x.nil? … elsif x.is_a?(String) … end`): that merge's entry is exactly `Dynamic[top]`, and the outer
28
+ # join's `Dynamic[top] | nil` is one the possible-nil gate declines anyway (#1876).
29
+ # - The `Dynamic`-member test keeps a narrowing whose surviving arms no longer cover the untyped value:
30
+ # `case x when String then … when Integer then … else raise end` leaves `Integer | String`, which is
31
+ # what `x` is past it.
32
+ # - Only the `Dynamic[top]` facet: a faceted `Dynamic[T]` carries static knowledge of its own (a
33
+ # plugin's or a signature's `T`), so a `nil` its narrowing leaves may be one the facet promised. Those
34
+ # keep today's join.
35
+ #
36
+ # Restoring `Dynamic[top]` is always sound, since every narrowed edge is a subset of it; what it drops
37
+ # is only the nameable arms, which an untyped union never needed to dispatch.
38
+ module UntypedRejoin
39
+ module_function
40
+
41
+ # `joined` with each qualifying local rebound to its `entry` binding. `arms` are the nodes whose
42
+ # evaluation produced the merged scopes (`nil` entries are skipped).
43
+ def apply(joined, entry, arms)
44
+ candidates = nil
45
+ joined.locals.each do |name, type|
46
+ next unless candidate?(entry.locals[name], type)
47
+
48
+ (candidates ||= []) << name
49
+ end
50
+ return joined if candidates.nil?
51
+
52
+ written = written_locals(arms)
53
+ kept = candidates.reject { |name| written.include?(name) }
54
+ return joined if kept.empty?
55
+
56
+ joined.with_rejoined_locals(kept.to_h { |name| [name, entry.locals[name]] })
57
+ end
58
+
59
+ def candidate?(entry_type, joined_type)
60
+ entry_type.is_a?(Type::Dynamic) && entry_type.static_facet.is_a?(Type::Top) &&
61
+ joined_type.is_a?(Type::Union) && joined_type.members.any?(Type::Dynamic)
62
+ end
63
+
64
+ def written_locals(arms)
65
+ names = Set.new
66
+ arms.each do |arm|
67
+ Source::NodeWalker.each(arm) do |node|
68
+ names << node.name if CapturedLocals::LOCAL_WRITE_NODES.include?(node.class)
69
+ end
70
+ end
71
+ names
72
+ end
73
+ end
74
+ end
75
+ end
@@ -6,6 +6,7 @@ require_relative "../cache/store"
6
6
  require_relative "../analysis/runner"
7
7
  require_relative "../analysis/incremental_session"
8
8
  require_relative "../cache/incremental_snapshot"
9
+ require_relative "../effects/identity"
9
10
 
10
11
  module Rigor
11
12
  module LanguageServer
@@ -24,6 +25,10 @@ module Rigor
24
25
  # are small (the per-publish path is already ≈2ms once Environment is warm).
25
26
  #
26
27
  # Invalidation:
28
+ # - Every whole-project round (`#project_diagnostics`) re-reads the configuration and compares the
29
+ # incremental snapshot fingerprint and the `effects:` identity with the ones its session was primed with,
30
+ # re-baselining on any change — so no file watcher or `didChangeConfiguration` is needed for
31
+ # `.rigor.yml`, `sig/`, `pre_eval:` or lockfile edits (#1613).
27
32
  # - `#invalidate!` drops the cached environment AND project scan + bumps the generation counter; the next
28
33
  # reader rebuilds. Watched-file changes (`workspace/didChangeWatchedFiles`) and configuration refreshes
29
34
  # (`workspace/didChangeConfiguration`) both trigger this — the next publish observes the new project
@@ -44,8 +49,13 @@ module Rigor
44
49
  # invalidated never reach the editor.
45
50
  attr_reader :configuration, :generation
46
51
 
47
- def initialize(configuration:)
52
+ # `configuration_loader` re-reads the configuration (e.g. `-> { Configuration.load(path) }`); a save round
53
+ # calls it so an edit to `.rigor.yml` (or a file it `includes:`) reaches the server without a restart. Nil
54
+ # keeps the configuration the context was built with.
55
+ def initialize(configuration:, configuration_loader: nil)
48
56
  @configuration = configuration
57
+ @configuration_loader = configuration_loader
58
+ @primed_key = nil
49
59
  @buffer_sources = {}
50
60
  @disk_feature_files = nil
51
61
  @environment_features = nil
@@ -110,16 +120,31 @@ module Rigor
110
120
  # The session is never persisted. Its state lives as long as this context, which is what a long-running
111
121
  # server needs, and writing it would race exactly the way the read-only {#cache_store} exists to avoid.
112
122
  # Seeding is therefore one-directional: the terminal warms the server, not the reverse.
123
+ #
124
+ # Every round recomputes {Cache::IncrementalSnapshot.fingerprint} (the function the CLI's `--incremental`
125
+ # keys its snapshot on — one input list) and compares it with the one the session was primed with. A
126
+ # session's recheck only sees source-file digests, so an edit to a `pre_eval:` file, an auto-detected
127
+ # `sig/*.rbs`, a lockfile or `.rigor.yml` would otherwise keep serving answers computed from the old
128
+ # inputs until the server restarts (#1613). A change — including a fingerprint that newly cannot be
129
+ # computed — drops the session and the world built from the old inputs and re-baselines, so the round equals
130
+ # a cold run. The `effects:` identity rides along because `Configuration#to_h` omits `effects:`, so the
131
+ # fingerprint alone does not move on an `effects:` edit. A fingerprint that was ALREADY uncomputable at
132
+ # priming is not a change: that session keeps rechecking.
133
+ # {#generation} is deliberately NOT bumped: the round that notices the change is itself computed from the
134
+ # new inputs, and the publisher discards a round whose generation moved.
113
135
  def project_diagnostics
136
+ reload_configuration
137
+ fingerprint = current_fingerprint
138
+ key = [fingerprint, Effects::Identity.digest(configuration: @configuration)]
139
+ rebaseline if @session_primed && key != @primed_key
114
140
  session = (@incremental_session ||= build_incremental_session)
115
141
  return session.recheck.diagnostics if @session_primed
116
142
 
117
143
  @session_primed = true
144
+ @primed_key = key
118
145
  diagnostics, = session.run_incremental(
119
146
  snapshot: Cache::IncrementalSnapshot.new(root: @configuration.cache_path),
120
- fingerprint: Cache::IncrementalSnapshot.fingerprint(
121
- configuration: @configuration, roots: @configuration.paths
122
- ),
147
+ fingerprint: fingerprint,
123
148
  persist: false
124
149
  )
125
150
  diagnostics
@@ -153,6 +178,13 @@ module Rigor
153
178
  # `workspace/didChangeConfiguration`.
154
179
  def invalidate!
155
180
  @generation += 1
181
+ reset_world
182
+ end
183
+
184
+ private
185
+
186
+ # Drops what was built from the previous inputs, without touching {#generation}.
187
+ def reset_world
156
188
  @environment = nil
157
189
  @environment_features = nil
158
190
  @disk_feature_files = nil
@@ -161,12 +193,31 @@ module Rigor
161
193
  # outlive them. The next save round primes a fresh one.
162
194
  @incremental_session = nil
163
195
  @session_primed = false
196
+ @primed_key = nil
164
197
  # Cache store stays — it's content-addressed; a stale env build won't be served because the file
165
198
  # digest mixed into the cache key has changed.
166
199
  nil
167
200
  end
168
201
 
169
- private
202
+ # A changed configuration may also move `cache_path`, so the store goes too (unlike {#invalidate!}).
203
+ def rebaseline
204
+ reset_world
205
+ @cache_store = nil
206
+ end
207
+
208
+ def current_fingerprint
209
+ Cache::IncrementalSnapshot.fingerprint(configuration: @configuration, roots: @configuration.paths)
210
+ end
211
+
212
+ # A load that raises (a half-typed `.rigor.yml`) keeps the previous configuration: the editor must not
213
+ # lose its diagnostics to a transient parse error, and the next round retries.
214
+ def reload_configuration
215
+ return unless @configuration_loader
216
+
217
+ @configuration = @configuration_loader.call
218
+ rescue StandardError
219
+ nil
220
+ end
170
221
 
171
222
  # The session shares this context's warm Environment, so a round does not rebuild the RBS universe. It
172
223
  # deliberately does NOT share the prebuilt ProjectScan: a round runs after a save, i.e. after the file
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../hashing/xxh3"
4
+
5
+ module Rigor
6
+ module Lens
7
+ # A site's anchor and contract-comment span (docs/internal-spec/lens.md § Anchors). The hashed material is the
8
+ # contract comment lines above the declaration (`#:`, `@rbs`, `@extrbs` and their continuation lines), the
9
+ # declaration's own bytes, and a `#:` comment trailing its last line. Documentation prose is not part of it.
10
+ class Anchor
11
+ Result = Data.define(:token, :line, :contract_comments)
12
+
13
+ # rbs-inline's annotation grammar (`#:`, `#[`, `#\s*@rbs`) plus `@extrbs`, matched from the `#`.
14
+ CONTRACT_COMMENT = /\A#(?::|\[|\s*@rbs\b|\s*@extrbs\b)/
15
+ # A `#:nodoc:`-style RDoc directive, which the rbs-inline plugin neutralises rather than reads.
16
+ RDOC_DIRECTIVE = /\A#:[a-z_][\w-]*:/
17
+ # A kept line that later comment lines may continue, with the spaces between its `#` and the tag.
18
+ CONTINUED_TAG = /\A#( *)@(?:rbs|extrbs)\b/
19
+
20
+ # @param source — the file's bytes (binary String)
21
+ # @param comments — the file's `Prism::Comment` list
22
+ def initialize(source, comments)
23
+ @source = source.b
24
+ @line_starts = [0]
25
+ @source.each_byte.with_index { |byte, index| @line_starts << (index + 1) if byte == 10 }
26
+ @comment_by_line = {}
27
+ comments.each do |comment|
28
+ next unless comment.is_a?(Prism::InlineComment)
29
+
30
+ @comment_by_line[comment.location.start_line] = comment
31
+ end
32
+ end
33
+
34
+ # The anchor of the declaration spanning bytes `start_offset...end_offset` and lines `start_line..end_line`.
35
+ def for_span(start_offset, end_offset, start_line, end_line, leading: true, trailing: true)
36
+ leading = leading ? leading_contract_comments(start_line) : []
37
+ trailing = trailing ? trailing_contract_comment(end_line, end_offset) : nil
38
+ material = +"".b
39
+ leading.each { |comment| material << line_text_from(comment.location.start_offset) << "\n" }
40
+ material << @source.byteslice(start_offset, end_offset - start_offset)
41
+ material << "\n" << line_text_from(trailing.location.start_offset) if trailing
42
+ line = leading.empty? ? start_line : leading.first.location.start_line
43
+ Result.new(token: "#{line}:#{Hashing::XXH3.anchor_hash(material)}", line: line,
44
+ contract_comments: leading + [trailing].compact)
45
+ end
46
+
47
+ private
48
+
49
+ # The contract comments among the contiguous comment-only lines directly above `start_line`, in file order,
50
+ # with rbs-inline's continuation lines below a kept `@rbs` / `@extrbs` line.
51
+ def leading_contract_comments(start_line)
52
+ run = []
53
+ line = start_line - 1
54
+ while line >= 1 && (comment = comment_only_line(line))
55
+ run.unshift(comment)
56
+ line -= 1
57
+ end
58
+ kept = []
59
+ tag_indent = nil
60
+ run.each do |comment|
61
+ text = comment_text(comment)
62
+ if tag_indent && hash_indent(text) > tag_indent
63
+ kept << comment
64
+ next
65
+ end
66
+
67
+ tag_indent = nil
68
+ next unless contract_comment?(text)
69
+
70
+ kept << comment
71
+ continued = text.match(CONTINUED_TAG)
72
+ tag_indent = continued && continued[1].size
73
+ end
74
+ kept
75
+ end
76
+
77
+ def trailing_contract_comment(end_line, end_offset)
78
+ comment = @comment_by_line[end_line]
79
+ return nil if comment.nil? || comment.location.start_offset < end_offset
80
+
81
+ text = comment_text(comment)
82
+ contract_comment?(text) && text.start_with?("#:") ? comment : nil
83
+ end
84
+
85
+ def contract_comment?(text)
86
+ text.match?(CONTRACT_COMMENT) && !text.match?(RDOC_DIRECTIVE)
87
+ end
88
+
89
+ # The spaces between a comment's `#` and its first non-space byte.
90
+ def hash_indent(text)
91
+ text[/\A#( *)/, 1].size
92
+ end
93
+
94
+ # The comment on `line` when nothing but whitespace precedes it.
95
+ def comment_only_line(line)
96
+ comment = @comment_by_line[line]
97
+ return nil if comment.nil?
98
+
99
+ prefix = @source.byteslice(@line_starts[line - 1], comment.location.start_offset - @line_starts[line - 1])
100
+ prefix.match?(/\A[ \t]*\z/) ? comment : nil
101
+ end
102
+
103
+ # The comment's text from its `#`, without a line terminator.
104
+ def comment_text(comment)
105
+ comment.location.slice.b.chomp.delete_suffix("\r")
106
+ end
107
+
108
+ # The bytes from `offset` to the end of its line, without the line terminator.
109
+ def line_text_from(offset)
110
+ line_end = @source.index("\n", offset) || @source.bytesize
111
+ @source.byteslice(offset, line_end - offset).delete_suffix("\r")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot"
4
+ require_relative "../reflection"
5
+
6
+ module Rigor
7
+ module Lens
8
+ # Reads a member's own RBS declaration into overloads with per-slot provenance (docs/internal-spec/lens.md
9
+ # § Members). A declaration the member inherits is not its contract; one written for its own class is, whether
10
+ # it comes from `sig/`, an inline comment, a plugin or a library.
11
+ class ContractReader
12
+ INFERRED_RETURN = "rigor:v1:inferred-return"
13
+ INFERRED_SIGNATURE = "rigor:v1:inferred-signature"
14
+
15
+ # `overloads` hold `{rbs, params, block, return}`; a return the contract leaves to inference is nil there,
16
+ # with `return_inferred` set. `buffers` are the declaring buffer names.
17
+ Contract = Data.define(:overloads, :provenance, :path, :return_inferred, :buffers)
18
+
19
+ def initialize(provenance)
20
+ @provenance = provenance
21
+ end
22
+
23
+ # The member's contract, or nil when it has none of its own. `name` is the Ruby method name.
24
+ def read(owner, scope, name, lookup_scope)
25
+ method = lookup(owner, scope, name, lookup_scope)
26
+ defs = method ? method.defs.select { |definition| definition.defined_in.to_s.delete_prefix("::") == owner } : []
27
+ return nil if defs.empty?
28
+
29
+ annotations = annotations_of(method, defs)
30
+ return nil if annotations.include?(INFERRED_SIGNATURE)
31
+
32
+ contract(defs, annotations)
33
+ rescue StandardError
34
+ unreadable("definition_build_failed", [])
35
+ end
36
+
37
+ # The declared type of the constant `name` (qualified), as a slot with its declaration's provenance, or nil
38
+ # when no RBS or inline declaration names it.
39
+ def read_constant(name, environment)
40
+ declaration = environment&.rbs_loader&.constant_declaration(name)
41
+ return nil if declaration.nil?
42
+
43
+ type, buffer = declaration
44
+ provenance, path = @provenance.classify(buffer)
45
+ return Slot.unavailable("not_computed") if provenance.nil?
46
+
47
+ Slot.from_rbs(type, provenance, path)
48
+ rescue StandardError
49
+ Slot.unavailable("not_computed")
50
+ end
51
+
52
+ private
53
+
54
+ def contract(defs, annotations)
55
+ buffers = defs.map { |definition| buffer_name(definition) }
56
+ classified = buffers.map { |buffer| @provenance.classify(buffer) }
57
+ return unreadable("not_computed", buffers) if classified.include?(nil)
58
+
59
+ return_inferred = annotations.include?(INFERRED_RETURN)
60
+ authored = annotations.select do |string|
61
+ string.start_with?("rigor:v1:") && !string.start_with?(INFERRED_RETURN)
62
+ end
63
+ # Each overload carries the provenance of the declaration it came from: a `sig/` overload extending a
64
+ # library declaration reads `sig`, the library one `library`.
65
+ overloads = defs.zip(classified).map do |definition, (provenance, path)|
66
+ overload(definition.type, provenance, path, return_inferred, authored)
67
+ end
68
+ provenance, path = classified.first
69
+ Contract.new(overloads: overloads, provenance: provenance, path: path,
70
+ return_inferred: return_inferred, buffers: buffers)
71
+ end
72
+
73
+ # The method's and its own declarations' annotation strings: the plugin's defaulted-slot marks and authored
74
+ # `%a{…}`.
75
+ def annotations_of(method, defs)
76
+ strings = method.annotations.map(&:string) + defs.flat_map { |definition| definition.annotations.map(&:string) }
77
+ strings.map(&:strip).uniq
78
+ end
79
+
80
+ def buffer_name(definition)
81
+ location = definition.member&.location
82
+ location ? location.buffer&.name.to_s : ""
83
+ end
84
+
85
+ def lookup(owner, scope, name, lookup_scope)
86
+ if scope == :singleton
87
+ Reflection.singleton_method_definition(owner, name.to_sym, scope: lookup_scope)
88
+ else
89
+ Reflection.instance_method_definition(owner, name.to_sym, scope: lookup_scope)
90
+ end
91
+ end
92
+
93
+ # A declaration Rigor cannot read: its buffer name did not survive the environment cache
94
+ # (`not_computed`), or building the member's definition failed (`definition_build_failed`). The member has a
95
+ # contract, so it is not inferred; every slot is unavailable, and so are its parameters (`params: null`).
96
+ def unreadable(reason, buffers)
97
+ Contract.new(overloads: [{ "rbs" => nil, "params" => nil, "block" => nil,
98
+ "return" => Slot.unavailable(reason) }],
99
+ provenance: nil, path: nil, return_inferred: false, buffers: buffers)
100
+ end
101
+
102
+ def overload(method_type, provenance, path, return_inferred, annotations)
103
+ function = method_type.type
104
+ params = function.respond_to?(:required_positionals) ? params_of(function, provenance, path) : nil
105
+ block = method_type.block && { "required" => method_type.block.required,
106
+ "type" => Slot.from_rbs(method_type.block.type.to_s, provenance, path) }
107
+ prefix = annotations.map { |string| "%a{#{string}} " }.join
108
+ return_slot = return_inferred ? nil : Slot.from_rbs(function.return_type.to_s, provenance, path)
109
+ { "rbs" => "#{prefix}#{method_type}", "params" => params, "block" => block, "return" => return_slot,
110
+ "return_rbs" => function.return_type.to_s }
111
+ end
112
+
113
+ def params_of(function, provenance, path)
114
+ (positional_params(function) + keyword_params(function)).map do |kind, name, param|
115
+ { "name" => name&.to_s, "kind" => kind, "type" => Slot.from_rbs(param.type.to_s, provenance, path) }
116
+ end
117
+ end
118
+
119
+ def positional_params(function)
120
+ function.required_positionals.map { |param| ["required", param.name, param] } +
121
+ function.optional_positionals.map { |param| ["optional", param.name, param] } +
122
+ [function.rest_positionals].compact.map { |param| ["rest", param.name, param] } +
123
+ function.trailing_positionals.map { |param| ["post", param.name, param] }
124
+ end
125
+
126
+ def keyword_params(function)
127
+ function.required_keywords.map { |name, param| ["keyreq", name, param] } +
128
+ function.optional_keywords.map { |name, param| ["key", name, param] } +
129
+ [function.rest_keywords].compact.map { |param| ["keyrest", param.name, param] }
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../version"
6
+ require_relative "../cache/engine_source"
7
+ require_relative "../analysis/run_cache_key"
8
+
9
+ module Rigor
10
+ module Lens
11
+ # Assembles the `lens/v0` document (docs/internal-spec/lens.md § Document) from a {Run::Outcome}.
12
+ class Document
13
+ SITE_FIELDS = %w[key kind name owner scope attribute members module_function builder ordinal span contract_span
14
+ anchor visibility contract params block body_return value alias_of].freeze
15
+
16
+ def initialize(outcome:, configuration:, root:, config_path:)
17
+ @outcome = outcome
18
+ @configuration = configuration
19
+ @root = root
20
+ @config_path = config_path
21
+ end
22
+
23
+ def to_h
24
+ { "schema" => SCHEMA, "revision" => REVISION, "producer" => producer, "context" => context,
25
+ "capabilities" => CAPABILITIES, "files" => @outcome.targets.map { |target| file(target) },
26
+ "members" => @outcome.member_rows }
27
+ end
28
+
29
+ private
30
+
31
+ def producer
32
+ identity = begin
33
+ Cache::EngineSource.process_identity
34
+ rescue StandardError
35
+ "unknown"
36
+ end
37
+ { "name" => "rigor", "version" => Rigor::VERSION, "engine_source" => identity }
38
+ end
39
+
40
+ def context
41
+ fields = {
42
+ "config" => @config_path && relative(File.expand_path(@config_path)),
43
+ "target_ruby" => @configuration.target_ruby,
44
+ "rbs_version" => rbs_version,
45
+ "signature_roots" => signature_roots.map { |root| relative(root) },
46
+ "signature_digest" => signature_digest,
47
+ "plugins" => @outcome.plugins,
48
+ "lockfiles" => lockfiles
49
+ }
50
+ fields.merge("digest" => Lens.digest(JSON.generate(fields)))
51
+ end
52
+
53
+ def rbs_version
54
+ require "rbs"
55
+ RBS::VERSION
56
+ end
57
+
58
+ def signature_roots
59
+ Environment.project_signature_roots(@configuration.signature_paths).map { |path| File.expand_path(path.to_s) }
60
+ end
61
+
62
+ def signature_digest
63
+ listing = signature_roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.rbs")) }.sort.map do |path|
64
+ "#{relative(path)}\t#{Lens.digest(File.binread(path))}\n"
65
+ end
66
+ Lens.digest(listing.join)
67
+ end
68
+
69
+ def lockfiles
70
+ Analysis::RunCacheKey.resolved_lockfile_paths(@configuration).compact.filter_map do |path|
71
+ absolute = File.expand_path(path.to_s)
72
+ next unless File.file?(absolute)
73
+
74
+ { "path" => relative(absolute), "digest" => Lens.digest(File.binread(absolute)) }
75
+ end
76
+ end
77
+
78
+ # Every field is present; those that apply only to some statuses are null elsewhere (lens.md § Files).
79
+ def file(target)
80
+ read = %w[ok parse_error].include?(target.status)
81
+ head = { "argument" => target.argument, "path" => target.status == "not_found" ? nil : target.path,
82
+ "status" => target.status, "reason" => target.status == "ok" ? nil : target.reason,
83
+ "digest" => file_digest(target, read) }
84
+ head.merge(analysed_fields(target.status == "ok" ? target : nil))
85
+ .merge("diagnostics" => read ? target.diagnostics.map { |diagnostic| diagnostic_row(diagnostic) } : nil)
86
+ .merge("inputs" => target.status == "ok" ? target.inputs : nil)
87
+ end
88
+
89
+ # The digest of the bytes the analysis read; a file that did not parse is read once more.
90
+ def file_digest(target, read)
91
+ return nil unless read
92
+ return target.reader.digest if target.reader
93
+
94
+ Lens.digest(File.binread(target.absolute))
95
+ end
96
+
97
+ def analysed_fields(target)
98
+ reader = target&.reader
99
+ { "complete" => reader && reader.omissions.empty?, "omissions" => reader&.omissions,
100
+ "sites" => reader&.sites&.map { |site| site.data.slice(*SITE_FIELDS) } }
101
+ end
102
+
103
+ def diagnostic_row(diagnostic)
104
+ { "rule" => diagnostic.qualified_rule, "severity" => diagnostic.severity.to_s,
105
+ "message" => diagnostic.message, "line" => diagnostic.line, "column" => diagnostic.column }
106
+ end
107
+
108
+ def relative(absolute)
109
+ Pathname(absolute).relative_path_from(Pathname(@root)).to_s
110
+ end
111
+ end
112
+ end
113
+ end