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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +28 -5
- data/docs/manual/02-cli-reference.md +23 -4
- data/docs/manual/03-configuration.md +21 -8
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +26 -9
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
- data/lib/rigor/analysis/check_rules.rb +68 -57
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +207 -40
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +139 -15
- data/lib/rigor/analysis/reachability/scan.rb +374 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +6 -4
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
- data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
- data/lib/rigor/analysis/runner.rb +62 -18
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +56 -12
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +79 -10
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +69 -6
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +117 -29
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +416 -1
- data/lib/rigor/inference/scope_indexer.rb +565 -61
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +307 -60
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +133 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +802 -0
- data/lib/rigor/lens/members.rb +203 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +322 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +41 -0
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +23 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +33 -17
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|