rigortype 0.1.16 → 0.1.17
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/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
- data/lib/rigor/analysis/check_rules.rb +149 -70
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +18 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner.rb +434 -37
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +50 -49
- data/lib/rigor/cache/incremental_snapshot.rb +147 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +99 -1
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +2 -7
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +125 -43
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +13 -3
- data/lib/rigor/environment/rbs_loader.rb +76 -3
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +140 -20
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +99 -59
- data/lib/rigor/inference/narrowing.rb +202 -5
- data/lib/rigor/inference/scope_indexer.rb +134 -7
- data/lib/rigor/inference/statement_evaluator.rb +105 -26
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/base.rb +20 -4
- data/lib/rigor/plugin/registry.rb +39 -1
- data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope.rb +123 -9
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +17 -0
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +5 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/inference.rbs +22 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +5 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +6 -1
- metadata +22 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
# ADR-24 slice 4a — records every *implicit-self* method call the
|
|
6
|
+
# inference engine could not resolve to any method (RBS tier miss +
|
|
7
|
+
# user-class ancestor-walk miss + not a `Dynamic` receiver), captured
|
|
8
|
+
# at the single engine choke-point where such a call falls through to
|
|
9
|
+
# `Dynamic[top]` (`ExpressionTyper#call_type_for` → `fallback_for`).
|
|
10
|
+
#
|
|
11
|
+
# ## Why a recorder, not a check-rule
|
|
12
|
+
#
|
|
13
|
+
# Slice 4 attempt 1 reimplemented self-call resolution inside
|
|
14
|
+
# `CheckRules` and produced 135 false positives on Rigor's own `lib`
|
|
15
|
+
# ([ADR-24](../../../docs/adr/24-self-method-call-resolution.md)
|
|
16
|
+
# § "Slice 4"): the second, weaker resolution path could not see
|
|
17
|
+
# `module_function` siblings, `Data.define` / `Struct` accessors, or
|
|
18
|
+
# mixin-contributed methods that the *engine's* real resolution
|
|
19
|
+
# handles. Recording at the engine's own miss point reuses that real
|
|
20
|
+
# resolution — those methods resolve before the miss, so they never
|
|
21
|
+
# reach the recorder. This module is the ADR-46 / ADR-47 "collect at
|
|
22
|
+
# evaluation time, never recompute" lesson applied to self-calls.
|
|
23
|
+
#
|
|
24
|
+
# A later slice consumes the recorded misses behind a confidently-
|
|
25
|
+
# closed-class gate to emit `call.self-undefined-method`, behind its
|
|
26
|
+
# own external-corpus false-positive gate. This slice (4a) lands the
|
|
27
|
+
# plumbing OFF by default — {active?} is false on a normal run, so the
|
|
28
|
+
# instrumented choke-point pays a single integer read and records
|
|
29
|
+
# nothing. Recording is purely observational; it never changes a
|
|
30
|
+
# diagnostic.
|
|
31
|
+
#
|
|
32
|
+
# Modelled on {DependencyRecorder}: process-thread-local accumulator,
|
|
33
|
+
# a cheap disabled fast path, and a frozen snapshot for consumers.
|
|
34
|
+
module SelfCallResolutionRecorder
|
|
35
|
+
KEY = :__rigor_self_call_resolution_recorder__
|
|
36
|
+
private_constant :KEY
|
|
37
|
+
|
|
38
|
+
# One unresolved implicit-self call. `class_name` is the receiver's
|
|
39
|
+
# statically known class (the enclosing `self` type); `method_name`
|
|
40
|
+
# the called name; `node` the Prism `CallNode` (held in-memory so the
|
|
41
|
+
# `call.self-undefined-method` collector can resolve its scope from the
|
|
42
|
+
# scope index and apply the closed-class gate); `path` / `line` /
|
|
43
|
+
# `column` locate the call site for the diagnostic.
|
|
44
|
+
UnresolvedSelfCall = Data.define(:class_name, :method_name, :node, :path, :line, :column)
|
|
45
|
+
|
|
46
|
+
# Mutable per-consumer accumulator, frozen into a {Record} snapshot
|
|
47
|
+
# when {record_for} returns. Dedupes by the full call tuple so a
|
|
48
|
+
# method body re-typed under several call-site signatures records the
|
|
49
|
+
# miss once.
|
|
50
|
+
class Accumulator
|
|
51
|
+
attr_reader :consumer, :calls
|
|
52
|
+
|
|
53
|
+
def initialize(consumer)
|
|
54
|
+
@consumer = consumer
|
|
55
|
+
@calls = []
|
|
56
|
+
@seen = Set.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add(call)
|
|
60
|
+
return unless @seen.add?(call)
|
|
61
|
+
|
|
62
|
+
@calls << call
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def snapshot
|
|
66
|
+
Record.new(consumer: consumer, calls: calls.dup.freeze)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Record = Data.define(:consumer, :calls)
|
|
71
|
+
|
|
72
|
+
# Module-level activation count so the disabled fast path ({active?})
|
|
73
|
+
# is a plain integer read rather than a `Thread.current` hash lookup —
|
|
74
|
+
# the instrumented choke-point is on the dispatch miss path, so a
|
|
75
|
+
# normal (non-recording) run must pay as little as possible.
|
|
76
|
+
@active_count = 0
|
|
77
|
+
@mutex = Mutex.new
|
|
78
|
+
|
|
79
|
+
module_function
|
|
80
|
+
|
|
81
|
+
# Activates recording for `consumer` (the path being analyzed) for the
|
|
82
|
+
# duration of the block and returns the frozen {Record}. Nests safely;
|
|
83
|
+
# restores the previous recorder on exit.
|
|
84
|
+
def record_for(consumer)
|
|
85
|
+
previous = Thread.current[KEY]
|
|
86
|
+
accumulator = Accumulator.new(consumer.to_s)
|
|
87
|
+
Thread.current[KEY] = accumulator
|
|
88
|
+
@mutex.synchronize { @active_count += 1 }
|
|
89
|
+
yield
|
|
90
|
+
accumulator.snapshot
|
|
91
|
+
ensure
|
|
92
|
+
Thread.current[KEY] = previous
|
|
93
|
+
@mutex.synchronize { @active_count -= 1 }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Plain integer read (GVL-atomic) — no `Thread.current` lookup on the
|
|
97
|
+
# disabled fast path.
|
|
98
|
+
def active?
|
|
99
|
+
@active_count.positive?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Records one unresolved implicit-self call. No-op when no consumer is
|
|
103
|
+
# active on this thread (another thread may have flipped {active?}).
|
|
104
|
+
def record(class_name:, method_name:, node:, path:, line:, column:)
|
|
105
|
+
accumulator = Thread.current[KEY]
|
|
106
|
+
return if accumulator.nil?
|
|
107
|
+
|
|
108
|
+
accumulator.add(
|
|
109
|
+
UnresolvedSelfCall.new(
|
|
110
|
+
class_name: class_name.to_s,
|
|
111
|
+
method_name: method_name.to_sym,
|
|
112
|
+
node: node,
|
|
113
|
+
path: path,
|
|
114
|
+
line: line,
|
|
115
|
+
column: column
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -134,8 +134,14 @@ module Rigor
|
|
|
134
134
|
# @return [Array<String>] the candidate owner class names
|
|
135
135
|
# for a bare method-name lookup. Empty when no override
|
|
136
136
|
# names this method.
|
|
137
|
+
NO_OWNERS = [].freeze
|
|
138
|
+
private_constant :NO_OWNERS
|
|
139
|
+
|
|
140
|
+
# Consulted on every dispatch (`try_static_refinement`); the miss
|
|
141
|
+
# case is overwhelmingly common, so share one frozen empty array
|
|
142
|
+
# instead of allocating a fresh `[]` per non-refined method.
|
|
137
143
|
def self.owners_for(method_name)
|
|
138
|
-
OWNERS_BY_METHOD[method_name.to_sym] ||
|
|
144
|
+
OWNERS_BY_METHOD[method_name.to_sym] || NO_OWNERS
|
|
139
145
|
end
|
|
140
146
|
end
|
|
141
147
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "../value_semantics"
|
|
5
6
|
|
|
6
7
|
module Rigor
|
|
7
8
|
module Cache
|
|
@@ -38,10 +39,14 @@ module Rigor
|
|
|
38
39
|
# can mutate after the entry is in a Descriptor.
|
|
39
40
|
|
|
40
41
|
class FileEntry
|
|
42
|
+
include Rigor::ValueSemantics
|
|
43
|
+
|
|
41
44
|
VALID_COMPARATORS = %i[digest mtime exists].freeze
|
|
42
45
|
|
|
43
46
|
attr_reader :path, :comparator, :value
|
|
44
47
|
|
|
48
|
+
value_fields :path, :comparator, :value
|
|
49
|
+
|
|
45
50
|
def initialize(path:, comparator:, value:)
|
|
46
51
|
unless VALID_COMPARATORS.include?(comparator)
|
|
47
52
|
raise ArgumentError,
|
|
@@ -57,20 +62,15 @@ module Rigor
|
|
|
57
62
|
def to_h
|
|
58
63
|
{ "path" => path, "comparator" => comparator.to_s, "value" => value }
|
|
59
64
|
end
|
|
60
|
-
|
|
61
|
-
def ==(other)
|
|
62
|
-
other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
|
|
63
|
-
end
|
|
64
|
-
alias eql? ==
|
|
65
|
-
|
|
66
|
-
def hash
|
|
67
|
-
[self.class, path, comparator, value].hash
|
|
68
|
-
end
|
|
69
65
|
end
|
|
70
66
|
|
|
71
67
|
class GemEntry
|
|
68
|
+
include Rigor::ValueSemantics
|
|
69
|
+
|
|
72
70
|
attr_reader :name, :requirement, :locked
|
|
73
71
|
|
|
72
|
+
value_fields :name, :requirement, :locked
|
|
73
|
+
|
|
74
74
|
def initialize(name:, requirement:, locked: nil)
|
|
75
75
|
@name = name.to_s.dup.freeze
|
|
76
76
|
@requirement = requirement.to_s.dup.freeze
|
|
@@ -81,20 +81,15 @@ module Rigor
|
|
|
81
81
|
def to_h
|
|
82
82
|
{ "name" => name, "requirement" => requirement, "locked" => locked }
|
|
83
83
|
end
|
|
84
|
-
|
|
85
|
-
def ==(other)
|
|
86
|
-
other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
|
|
87
|
-
end
|
|
88
|
-
alias eql? ==
|
|
89
|
-
|
|
90
|
-
def hash
|
|
91
|
-
[self.class, name, requirement, locked].hash
|
|
92
|
-
end
|
|
93
84
|
end
|
|
94
85
|
|
|
95
86
|
class PluginEntry
|
|
87
|
+
include Rigor::ValueSemantics
|
|
88
|
+
|
|
96
89
|
attr_reader :id, :version, :config_hash
|
|
97
90
|
|
|
91
|
+
value_fields :id, :version, :config_hash
|
|
92
|
+
|
|
98
93
|
def initialize(id:, version:, config_hash: nil)
|
|
99
94
|
@id = id.to_s.dup.freeze
|
|
100
95
|
@version = version.to_s.dup.freeze
|
|
@@ -105,21 +100,15 @@ module Rigor
|
|
|
105
100
|
def to_h
|
|
106
101
|
{ "id" => id, "version" => version, "config_hash" => config_hash }
|
|
107
102
|
end
|
|
108
|
-
|
|
109
|
-
def ==(other)
|
|
110
|
-
other.is_a?(PluginEntry) &&
|
|
111
|
-
other.id == id && other.version == version && other.config_hash == config_hash
|
|
112
|
-
end
|
|
113
|
-
alias eql? ==
|
|
114
|
-
|
|
115
|
-
def hash
|
|
116
|
-
[self.class, id, version, config_hash].hash
|
|
117
|
-
end
|
|
118
103
|
end
|
|
119
104
|
|
|
120
105
|
class ConfigEntry
|
|
106
|
+
include Rigor::ValueSemantics
|
|
107
|
+
|
|
121
108
|
attr_reader :key, :value_hash
|
|
122
109
|
|
|
110
|
+
value_fields :key, :value_hash
|
|
111
|
+
|
|
123
112
|
def initialize(key:, value_hash:)
|
|
124
113
|
@key = key.to_s.dup.freeze
|
|
125
114
|
@value_hash = value_hash.to_s.dup.freeze
|
|
@@ -129,15 +118,6 @@ module Rigor
|
|
|
129
118
|
def to_h
|
|
130
119
|
{ "key" => key, "value_hash" => value_hash }
|
|
131
120
|
end
|
|
132
|
-
|
|
133
|
-
def ==(other)
|
|
134
|
-
other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
|
|
135
|
-
end
|
|
136
|
-
alias eql? ==
|
|
137
|
-
|
|
138
|
-
def hash
|
|
139
|
-
[self.class, key, value_hash].hash
|
|
140
|
-
end
|
|
141
121
|
end
|
|
142
122
|
|
|
143
123
|
# Per-(gem, version, mode) row carrying the cache slice
|
|
@@ -155,10 +135,14 @@ module Rigor
|
|
|
155
135
|
# the inferred shapes depend on whether RBS overrides the
|
|
156
136
|
# walk.
|
|
157
137
|
class DependencyEntry
|
|
138
|
+
include Rigor::ValueSemantics
|
|
139
|
+
|
|
158
140
|
VALID_MODES = %i[disabled when_missing full].freeze
|
|
159
141
|
|
|
160
142
|
attr_reader :gem_name, :gem_version, :mode
|
|
161
143
|
|
|
144
|
+
value_fields :gem_name, :gem_version, :mode
|
|
145
|
+
|
|
162
146
|
def initialize(gem_name:, gem_version:, mode:)
|
|
163
147
|
unless VALID_MODES.include?(mode)
|
|
164
148
|
raise ArgumentError,
|
|
@@ -174,18 +158,6 @@ module Rigor
|
|
|
174
158
|
def to_h
|
|
175
159
|
{ "gem_name" => gem_name, "gem_version" => gem_version, "mode" => mode.to_s }
|
|
176
160
|
end
|
|
177
|
-
|
|
178
|
-
def ==(other)
|
|
179
|
-
other.is_a?(DependencyEntry) &&
|
|
180
|
-
other.gem_name == gem_name &&
|
|
181
|
-
other.gem_version == gem_version &&
|
|
182
|
-
other.mode == mode
|
|
183
|
-
end
|
|
184
|
-
alias eql? ==
|
|
185
|
-
|
|
186
|
-
def hash
|
|
187
|
-
[self.class, gem_name, gem_version, mode].hash
|
|
188
|
-
end
|
|
189
161
|
end
|
|
190
162
|
|
|
191
163
|
# Raised when {.compose} encounters incompatible entries
|
|
@@ -206,6 +178,20 @@ module Rigor
|
|
|
206
178
|
freeze
|
|
207
179
|
end
|
|
208
180
|
|
|
181
|
+
# ADR-45 — re-validates this descriptor's recorded {FileEntry}s
|
|
182
|
+
# against the current filesystem. Used by the record-and-validate
|
|
183
|
+
# run-result cache: a value cached alongside its dependency
|
|
184
|
+
# descriptor is fresh iff every recorded file still matches. Only
|
|
185
|
+
# `files` are checked — non-file inputs (config / gems / version)
|
|
186
|
+
# belong in the cache *key*, not the validated dependency set — so
|
|
187
|
+
# a descriptor carrying any non-file slot is never considered fresh
|
|
188
|
+
# (it was built wrong for this use).
|
|
189
|
+
def fresh?
|
|
190
|
+
return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
|
|
191
|
+
|
|
192
|
+
files.all? { |entry| file_entry_fresh?(entry) }
|
|
193
|
+
end
|
|
194
|
+
|
|
209
195
|
# File-comparator strictness ordering. `:digest` is strictest
|
|
210
196
|
# (deterministic across machines); `:mtime` is cheaper but
|
|
211
197
|
# local; `:exists` is the weakest signal. When two
|
|
@@ -290,6 +276,21 @@ module Rigor
|
|
|
290
276
|
|
|
291
277
|
private
|
|
292
278
|
|
|
279
|
+
def file_entry_fresh?(entry)
|
|
280
|
+
case entry.comparator
|
|
281
|
+
when :digest
|
|
282
|
+
File.file?(entry.path) && Digest::SHA256.file(entry.path).hexdigest == entry.value
|
|
283
|
+
when :mtime
|
|
284
|
+
File.exist?(entry.path) && File.mtime(entry.path).to_i.to_s == entry.value
|
|
285
|
+
when :exists
|
|
286
|
+
File.exist?(entry.path).to_s == entry.value
|
|
287
|
+
else
|
|
288
|
+
false
|
|
289
|
+
end
|
|
290
|
+
rescue StandardError
|
|
291
|
+
false
|
|
292
|
+
end
|
|
293
|
+
|
|
293
294
|
def sort_entries(entries, key)
|
|
294
295
|
entries.sort_by { |e| e.to_h.fetch(key).to_s }
|
|
295
296
|
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Cache
|
|
8
|
+
# ADR-46 — disk persistence for the incremental analyzer's per-file
|
|
9
|
+
# state, so a `--incremental` session survives across processes (one
|
|
10
|
+
# `rigor check` invocation reads the prior run's per-file diagnostics +
|
|
11
|
+
# dependency graph, re-analyzes only the changed closure, and serves the
|
|
12
|
+
# rest from disk).
|
|
13
|
+
#
|
|
14
|
+
# Unlike ADR-45's whole-run cache (record-and-validate ONE entry,
|
|
15
|
+
# invalidated by any analyzed-file change), this snapshot is loaded
|
|
16
|
+
# UNCONDITIONALLY when the global fingerprint matches — the per-file
|
|
17
|
+
# digests *inside* it drive the incremental re-analysis decision; they
|
|
18
|
+
# do not gate the load. The fingerprint captures the inputs whose change
|
|
19
|
+
# requires a full rebuild — the resolved configuration, the RBS
|
|
20
|
+
# environment, the engine version — but NOT the analyzed source
|
|
21
|
+
# contents. A fingerprint mismatch (config / gem / version change) drops
|
|
22
|
+
# the snapshot and forces a full re-analysis, the conservative
|
|
23
|
+
# direction.
|
|
24
|
+
#
|
|
25
|
+
# Every operation is fault-tolerant: a missing, unreadable, schema-
|
|
26
|
+
# mismatched, fingerprint-mismatched, or corrupt snapshot loads as nil
|
|
27
|
+
# (→ a cold full run), and a write failure is swallowed (→ the next run
|
|
28
|
+
# is cold). A cache must never break a run (the ADR-45 invariant).
|
|
29
|
+
class IncrementalSnapshot
|
|
30
|
+
# Bump when the on-disk shape changes so stale snapshots are ignored
|
|
31
|
+
# rather than mis-deserialized.
|
|
32
|
+
SCHEMA = 4
|
|
33
|
+
|
|
34
|
+
# The persisted per-file state.
|
|
35
|
+
# `cache` maps an analyzed file to its diagnostics.
|
|
36
|
+
# `sources` maps a consumer to the Set of source files it read from.
|
|
37
|
+
# `digests` maps a file to its content digest at analysis time.
|
|
38
|
+
# `analyzed` is the ordered analyzed-file list.
|
|
39
|
+
# ADR-46 slice 4:
|
|
40
|
+
# `symbol_sources` maps a consumer to { source_path → Set<"ClassName#method"> }.
|
|
41
|
+
# `ancestry_sources` maps a consumer to Set<source_path> (class-ancestry deps).
|
|
42
|
+
# `symbol_fingerprints` maps a path to { "ClassName#method" => sha256_hex }.
|
|
43
|
+
# ADR-46 slice 3:
|
|
44
|
+
# `missing` maps a consumer to Set<"kind:name"> it looked up and missed.
|
|
45
|
+
# `class_decls` maps a path to Set<qualified class name> it declares.
|
|
46
|
+
Payload = Data.define(:cache, :sources, :digests, :analyzed,
|
|
47
|
+
:symbol_sources, :ancestry_sources, :symbol_fingerprints,
|
|
48
|
+
:missing, :class_decls)
|
|
49
|
+
|
|
50
|
+
# The global fingerprint that gates a snapshot load: a digest of the
|
|
51
|
+
# inputs whose change requires a full rebuild — the engine version +
|
|
52
|
+
# schema, the resolved configuration, the analysis **roots** (the path
|
|
53
|
+
# arguments, e.g. `["lib"]`, NOT the expanded file list — so a snapshot
|
|
54
|
+
# is keyed to an invocation's roots but adding / removing a file under
|
|
55
|
+
# them is handled incrementally by the session, not a full rebuild), the
|
|
56
|
+
# resolved gem set (`Gemfile.lock` / `rbs_collection`), and the project's
|
|
57
|
+
# own RBS (`signature_paths` file contents). Built WITHOUT constructing
|
|
58
|
+
# the RBS environment so the warm path can gate the load cheaply, before
|
|
59
|
+
# the costly env build. The `--verify-incremental` gate is the safety net
|
|
60
|
+
# for any under-capture (it would surface as an incremental-vs-full
|
|
61
|
+
# mismatch). Returns nil on any error → the caller falls back to a
|
|
62
|
+
# non-persisted run.
|
|
63
|
+
def self.fingerprint(configuration:, roots:)
|
|
64
|
+
parts = [
|
|
65
|
+
"engine:#{Rigor::VERSION}:#{SCHEMA}",
|
|
66
|
+
"config:#{Digest::SHA256.hexdigest(Marshal.dump(configuration.to_h))}",
|
|
67
|
+
"roots:#{Array(roots).map(&:to_s).sort.join("\n")}",
|
|
68
|
+
"gems:#{digest_file_if_present('Gemfile.lock')}",
|
|
69
|
+
"rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
|
|
70
|
+
"sig:#{digest_signature_paths(configuration.signature_paths)}"
|
|
71
|
+
]
|
|
72
|
+
Digest::SHA256.hexdigest(parts.join("\x00"))
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.digest_file_if_present(path)
|
|
78
|
+
File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :digest_file_if_present
|
|
81
|
+
|
|
82
|
+
# Content-digest every `.rbs` under the configured signature paths
|
|
83
|
+
# (sorted for determinism) so a project RBS edit invalidates the
|
|
84
|
+
# snapshot. Sig trees are small; content (not mtime) keeps it stable
|
|
85
|
+
# across checkouts.
|
|
86
|
+
def self.digest_signature_paths(signature_paths)
|
|
87
|
+
globbed = Array(signature_paths).flat_map do |entry|
|
|
88
|
+
File.directory?(entry) ? Dir.glob(File.join(entry, "**", "*.rbs")) : [entry]
|
|
89
|
+
end
|
|
90
|
+
files = globbed.select { |path| File.file?(path) }.sort
|
|
91
|
+
digest = Digest::SHA256.new
|
|
92
|
+
files.each { |path| digest << path << "\0" << Digest::SHA256.file(path).hexdigest << "\0" }
|
|
93
|
+
digest.hexdigest
|
|
94
|
+
end
|
|
95
|
+
private_class_method :digest_signature_paths
|
|
96
|
+
|
|
97
|
+
def initialize(root:)
|
|
98
|
+
@path = File.join(root.to_s, "incremental", "snapshot.bin")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
attr_reader :path
|
|
102
|
+
|
|
103
|
+
# The stored {Payload}, or nil when absent / unreadable / schema or
|
|
104
|
+
# fingerprint mismatch / corrupt. Never raises.
|
|
105
|
+
def load(fingerprint:)
|
|
106
|
+
data = Marshal.load(File.binread(@path)) # rubocop:disable Security/MarshalLoad
|
|
107
|
+
return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
|
|
108
|
+
|
|
109
|
+
Payload.new(
|
|
110
|
+
cache: data[:cache], sources: data[:sources],
|
|
111
|
+
digests: data[:digests], analyzed: data[:analyzed],
|
|
112
|
+
symbol_sources: data[:symbol_sources] || {},
|
|
113
|
+
ancestry_sources: data[:ancestry_sources] || {},
|
|
114
|
+
symbol_fingerprints: data[:symbol_fingerprints] || {},
|
|
115
|
+
missing: data[:missing] || {},
|
|
116
|
+
class_decls: data[:class_decls] || {}
|
|
117
|
+
)
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Persist `payload` under `fingerprint`. Writes via a temp file +
|
|
123
|
+
# atomic rename so a concurrent reader never sees a half-written
|
|
124
|
+
# snapshot. Returns true on success, false on any failure (never
|
|
125
|
+
# raises).
|
|
126
|
+
def save(fingerprint:, payload:)
|
|
127
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
128
|
+
blob = Marshal.dump(
|
|
129
|
+
schema: SCHEMA, fingerprint: fingerprint,
|
|
130
|
+
cache: payload.cache, sources: payload.sources,
|
|
131
|
+
digests: payload.digests, analyzed: payload.analyzed,
|
|
132
|
+
symbol_sources: payload.symbol_sources,
|
|
133
|
+
ancestry_sources: payload.ancestry_sources,
|
|
134
|
+
symbol_fingerprints: payload.symbol_fingerprints,
|
|
135
|
+
missing: payload.missing,
|
|
136
|
+
class_decls: payload.class_decls
|
|
137
|
+
)
|
|
138
|
+
tmp = "#{@path}.#{Process.pid}.tmp"
|
|
139
|
+
File.binwrite(tmp, blob)
|
|
140
|
+
File.rename(tmp, @path)
|
|
141
|
+
true
|
|
142
|
+
rescue StandardError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rbs_descriptor"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Cache
|
|
7
|
+
# Base for the RBS-derived cache producers.
|
|
8
|
+
#
|
|
9
|
+
# Every producer (`RbsKnownClassNames`, `RbsConstantTable`,
|
|
10
|
+
# `RbsEnvironment`, the ancestor / type-param / definition tables, …)
|
|
11
|
+
# repeated the identical `fetch` wiring: build the RBS descriptor,
|
|
12
|
+
# then `store.fetch_or_compute` under the producer's id, yielding to
|
|
13
|
+
# the producer's `compute`. Only the `PRODUCER_ID` constant and the
|
|
14
|
+
# `compute(loader)` body actually differ between producers.
|
|
15
|
+
#
|
|
16
|
+
# Subclasses declare `PRODUCER_ID` and a (private) `self.compute`;
|
|
17
|
+
# this base owns `fetch`. `self::PRODUCER_ID` resolves the constant on
|
|
18
|
+
# the concrete subclass, and `compute(loader)` dispatches to its
|
|
19
|
+
# private class method. See the `_CacheProducer` RBS interface for the
|
|
20
|
+
# structural contract.
|
|
21
|
+
class RbsCacheProducer
|
|
22
|
+
def self.fetch(loader:, store:)
|
|
23
|
+
descriptor = RbsDescriptor.build(loader)
|
|
24
|
+
store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
25
|
+
compute(loader)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_cache_producer"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Cache
|
|
@@ -22,19 +23,12 @@ module Rigor
|
|
|
22
23
|
# Cache descriptor shape is shared with every other cache
|
|
23
24
|
# producer that depends on the RBS environment — see
|
|
24
25
|
# {RbsDescriptor.build}.
|
|
25
|
-
class RbsClassAncestorTable
|
|
26
|
+
class RbsClassAncestorTable < RbsCacheProducer
|
|
26
27
|
PRODUCER_ID = "rbs.class_ancestor_table"
|
|
27
28
|
|
|
28
29
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
29
30
|
# @param store [Rigor::Cache::Store]
|
|
30
31
|
# @return [Hash{String => Array<String>}]
|
|
31
|
-
def self.fetch(loader:, store:)
|
|
32
|
-
descriptor = RbsDescriptor.build(loader)
|
|
33
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
34
|
-
compute(loader)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
32
|
def self.compute(loader)
|
|
39
33
|
table = {}
|
|
40
34
|
loader.each_known_class_name do |name|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_cache_producer"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Cache
|
|
@@ -22,19 +23,12 @@ module Rigor
|
|
|
22
23
|
# Cache descriptor shape is shared with every other cache
|
|
23
24
|
# producer that depends on the RBS environment — see
|
|
24
25
|
# {RbsDescriptor.build}.
|
|
25
|
-
class RbsClassTypeParamNames
|
|
26
|
+
class RbsClassTypeParamNames < RbsCacheProducer
|
|
26
27
|
PRODUCER_ID = "rbs.class_type_param_names"
|
|
27
28
|
|
|
28
29
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
29
30
|
# @param store [Rigor::Cache::Store]
|
|
30
31
|
# @return [Hash{String => Array<Symbol>}]
|
|
31
|
-
def self.fetch(loader:, store:)
|
|
32
|
-
descriptor = RbsDescriptor.build(loader)
|
|
33
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
34
|
-
compute(loader)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
32
|
def self.compute(loader)
|
|
39
33
|
table = {}
|
|
40
34
|
loader.each_known_class_name do |name|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_cache_producer"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Cache
|
|
@@ -15,19 +16,12 @@ module Rigor
|
|
|
15
16
|
# Cache descriptor shape is shared with every other cache
|
|
16
17
|
# producer that depends on the RBS environment — see
|
|
17
18
|
# {RbsDescriptor.build} for the slot definitions.
|
|
18
|
-
class RbsConstantTable
|
|
19
|
+
class RbsConstantTable < RbsCacheProducer
|
|
19
20
|
PRODUCER_ID = "rbs.constant_type_table"
|
|
20
21
|
|
|
21
22
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
22
23
|
# @param store [Rigor::Cache::Store]
|
|
23
24
|
# @return [Hash{String => Rigor::Type}]
|
|
24
|
-
def self.fetch(loader:, store:)
|
|
25
|
-
descriptor = RbsDescriptor.build(loader)
|
|
26
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
27
|
-
compute(loader)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
25
|
def self.compute(loader)
|
|
32
26
|
table = {}
|
|
33
27
|
loader.each_constant_decl do |name, entry|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_cache_producer"
|
|
4
5
|
require_relative "rbs_environment_marshal_patch"
|
|
5
6
|
|
|
6
7
|
module Rigor
|
|
@@ -26,19 +27,12 @@ module Rigor
|
|
|
26
27
|
# Cache descriptor shape is shared with every other cache
|
|
27
28
|
# producer that depends on the RBS environment — see
|
|
28
29
|
# {RbsDescriptor.build}.
|
|
29
|
-
class RbsEnvironment
|
|
30
|
+
class RbsEnvironment < RbsCacheProducer
|
|
30
31
|
PRODUCER_ID = "rbs.environment"
|
|
31
32
|
|
|
32
33
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
33
34
|
# @param store [Rigor::Cache::Store]
|
|
34
35
|
# @return [::RBS::Environment]
|
|
35
|
-
def self.fetch(loader:, store:)
|
|
36
|
-
descriptor = RbsDescriptor.build(loader)
|
|
37
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
38
|
-
compute(loader)
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
36
|
def self.compute(loader)
|
|
43
37
|
Rigor::Environment::RbsLoader.build_env_for(
|
|
44
38
|
libraries: loader.libraries,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rbs_descriptor"
|
|
4
|
+
require_relative "rbs_cache_producer"
|
|
4
5
|
require_relative "rbs_environment_marshal_patch"
|
|
5
6
|
|
|
6
7
|
module Rigor
|
|
@@ -23,19 +24,12 @@ module Rigor
|
|
|
23
24
|
#
|
|
24
25
|
# Marshal-cleanness of `RBS::Definition` is enabled by the
|
|
25
26
|
# v0.0.9 C2 `RBS::Location` patch.
|
|
26
|
-
class RbsInstanceDefinitions
|
|
27
|
+
class RbsInstanceDefinitions < RbsCacheProducer
|
|
27
28
|
PRODUCER_ID = "rbs.instance_definitions"
|
|
28
29
|
|
|
29
30
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
30
31
|
# @param store [Rigor::Cache::Store]
|
|
31
32
|
# @return [Hash{String => RBS::Definition}]
|
|
32
|
-
def self.fetch(loader:, store:)
|
|
33
|
-
descriptor = RbsDescriptor.build(loader)
|
|
34
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
35
|
-
compute(loader)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
33
|
def self.compute(loader)
|
|
40
34
|
table = {}
|
|
41
35
|
loader.each_known_class_name do |name|
|
|
@@ -51,19 +45,12 @@ module Rigor
|
|
|
51
45
|
# Singleton-side equivalent of {RbsInstanceDefinitions}.
|
|
52
46
|
# Caches the full `Hash<String, RBS::Definition>` for the
|
|
53
47
|
# singleton class of every RBS-known class.
|
|
54
|
-
class RbsSingletonDefinitions
|
|
48
|
+
class RbsSingletonDefinitions < RbsCacheProducer
|
|
55
49
|
PRODUCER_ID = "rbs.singleton_definitions"
|
|
56
50
|
|
|
57
51
|
# @param loader [Rigor::Environment::RbsLoader]
|
|
58
52
|
# @param store [Rigor::Cache::Store]
|
|
59
53
|
# @return [Hash{String => RBS::Definition}]
|
|
60
|
-
def self.fetch(loader:, store:)
|
|
61
|
-
descriptor = RbsDescriptor.build(loader)
|
|
62
|
-
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
63
|
-
compute(loader)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
54
|
def self.compute(loader)
|
|
68
55
|
table = {}
|
|
69
56
|
loader.each_known_class_name do |name|
|