rigortype 0.0.7 → 0.0.8
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/diagnostic.rb +28 -2
- data/lib/rigor/cache/descriptor.rb +278 -0
- data/lib/rigor/cache/rbs_constant_table.rb +83 -0
- data/lib/rigor/cache/store.rb +261 -0
- data/lib/rigor/cli.rb +56 -7
- data/lib/rigor/environment/rbs_loader.rb +11 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38aa66f97f5bed742a36c156ceee7f13d3181597b74c32329b693b3c219852b3
|
|
4
|
+
data.tar.gz: 69af0ccbf42c2b890b78a558d574b49293ff6b4880875568137098624e66d3f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8a9e0ee0461e2f0b2779981fa95758e0b7b5bd02668254dc1cb2d82002565b383d866534edff6e75c53aa0a9ae2fdb59832a35788ccfda3cb4558982bed61b15
|
|
7
|
+
data.tar.gz: 9699b63098d16e1959177684a849923384196f0dac55a532a3a0ecdff77d45c76230c3b72a273f59ec237059127007d7bb1ae01be9c9cf8b9613a02033b89bb5
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
module Rigor
|
|
4
4
|
module Analysis
|
|
5
5
|
class Diagnostic
|
|
6
|
-
|
|
6
|
+
# The default source family. Matches the existing analyzer-
|
|
7
|
+
# internal rule families; serialised as `"builtin"` and is the
|
|
8
|
+
# baseline against which non-default families are recognised.
|
|
9
|
+
DEFAULT_SOURCE_FAMILY = :builtin
|
|
10
|
+
|
|
11
|
+
attr_reader :path, :line, :column, :message, :severity, :rule, :source_family
|
|
7
12
|
|
|
8
13
|
# `rule:` is the stable identifier (a kebab-case string)
|
|
9
14
|
# of the diagnostic's source rule. It is used by the
|
|
@@ -12,8 +17,16 @@ module Rigor
|
|
|
12
17
|
# category. Diagnostics not produced by `CheckRules`
|
|
13
18
|
# (parse errors, path errors, internal analyzer errors)
|
|
14
19
|
# may leave `rule` as nil and stay unsuppressible.
|
|
20
|
+
#
|
|
21
|
+
# `source_family:` names the producer of the rule. The default
|
|
22
|
+
# `:builtin` covers analyzer-internal rules; future families
|
|
23
|
+
# like `:rbs_extended`, `:generated`, or `"plugin.<id>"` (per
|
|
24
|
+
# ADR-2 § "Plugin Diagnostic Provenance") let consumers
|
|
25
|
+
# distinguish where a diagnostic originated without committing
|
|
26
|
+
# to the plugin API itself.
|
|
15
27
|
# rubocop:disable Metrics/ParameterLists
|
|
16
|
-
def initialize(path:, line:, column:, message:, severity: :error, rule: nil
|
|
28
|
+
def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
|
|
29
|
+
source_family: DEFAULT_SOURCE_FAMILY)
|
|
17
30
|
# rubocop:enable Metrics/ParameterLists
|
|
18
31
|
@path = path
|
|
19
32
|
@line = line
|
|
@@ -21,12 +34,24 @@ module Rigor
|
|
|
21
34
|
@message = message
|
|
22
35
|
@severity = severity
|
|
23
36
|
@rule = rule
|
|
37
|
+
@source_family = source_family
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
def error?
|
|
27
41
|
severity == :error
|
|
28
42
|
end
|
|
29
43
|
|
|
44
|
+
# The fully-qualified rule identifier — `<source_family>.<rule>`
|
|
45
|
+
# when the source is non-default, or just `<rule>` for the
|
|
46
|
+
# `:builtin` family. Returns nil when `rule` itself is nil
|
|
47
|
+
# (e.g. parse errors and internal-analyzer errors).
|
|
48
|
+
def qualified_rule
|
|
49
|
+
return nil if rule.nil?
|
|
50
|
+
return rule if source_family == DEFAULT_SOURCE_FAMILY
|
|
51
|
+
|
|
52
|
+
"#{source_family}.#{rule}"
|
|
53
|
+
end
|
|
54
|
+
|
|
30
55
|
def to_h
|
|
31
56
|
{
|
|
32
57
|
"path" => path,
|
|
@@ -34,6 +59,7 @@ module Rigor
|
|
|
34
59
|
"column" => column,
|
|
35
60
|
"severity" => severity.to_s,
|
|
36
61
|
"rule" => rule,
|
|
62
|
+
"source_family" => source_family.to_s,
|
|
37
63
|
"message" => message
|
|
38
64
|
}
|
|
39
65
|
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Cache
|
|
8
|
+
# Cache invalidation descriptor — the typed-slot schema fixed by
|
|
9
|
+
# [`docs/design/20260505-cache-slice-taxonomy.md`](../../../docs/design/20260505-cache-slice-taxonomy.md).
|
|
10
|
+
# Pure value object: no I/O, no global state, fully immutable
|
|
11
|
+
# after construction. The storage layer
|
|
12
|
+
# ([`Rigor::Cache::Store`](store.rb), v0.0.8 slice 2) consumes
|
|
13
|
+
# descriptors but does not extend them.
|
|
14
|
+
#
|
|
15
|
+
# The descriptor has four slots (`files`, `gems`, `plugins`,
|
|
16
|
+
# `configs`); every slot is an array of typed entries; an empty
|
|
17
|
+
# array means "no dependency in this slot". Composition unions
|
|
18
|
+
# by key per slot; conflicts on the comparison fields raise
|
|
19
|
+
# {Conflict}.
|
|
20
|
+
#
|
|
21
|
+
# See ADR-2 § "Registration, Configuration, and Caching" for
|
|
22
|
+
# the design rationale and ADR-6 for the storage backend
|
|
23
|
+
# decisions that consume this schema.
|
|
24
|
+
class Descriptor # rubocop:disable Metrics/ClassLength
|
|
25
|
+
# Bumped on incompatible schema changes. The storage layer
|
|
26
|
+
# mixes this into the cache key, so a bump implicitly
|
|
27
|
+
# invalidates every cached value.
|
|
28
|
+
SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
# Per-slot entry value objects. Constructors validate enums /
|
|
31
|
+
# required fields and freeze the resulting struct so no caller
|
|
32
|
+
# can mutate after the entry is in a Descriptor.
|
|
33
|
+
|
|
34
|
+
class FileEntry
|
|
35
|
+
VALID_COMPARATORS = %i[digest mtime exists].freeze
|
|
36
|
+
|
|
37
|
+
attr_reader :path, :comparator, :value
|
|
38
|
+
|
|
39
|
+
def initialize(path:, comparator:, value:)
|
|
40
|
+
unless VALID_COMPARATORS.include?(comparator)
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"FileEntry comparator must be one of #{VALID_COMPARATORS.inspect}, got #{comparator.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@path = path.to_s.dup.freeze
|
|
46
|
+
@comparator = comparator
|
|
47
|
+
@value = value.to_s.dup.freeze
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{ "path" => path, "comparator" => comparator.to_s, "value" => value }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ==(other)
|
|
56
|
+
other.is_a?(FileEntry) && other.path == path && other.comparator == comparator && other.value == value
|
|
57
|
+
end
|
|
58
|
+
alias eql? ==
|
|
59
|
+
|
|
60
|
+
def hash
|
|
61
|
+
[self.class, path, comparator, value].hash
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class GemEntry
|
|
66
|
+
attr_reader :name, :requirement, :locked
|
|
67
|
+
|
|
68
|
+
def initialize(name:, requirement:, locked: nil)
|
|
69
|
+
@name = name.to_s.dup.freeze
|
|
70
|
+
@requirement = requirement.to_s.dup.freeze
|
|
71
|
+
@locked = locked.nil? ? nil : locked.to_s.dup.freeze
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_h
|
|
76
|
+
{ "name" => name, "requirement" => requirement, "locked" => locked }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ==(other)
|
|
80
|
+
other.is_a?(GemEntry) && other.name == name && other.requirement == requirement && other.locked == locked
|
|
81
|
+
end
|
|
82
|
+
alias eql? ==
|
|
83
|
+
|
|
84
|
+
def hash
|
|
85
|
+
[self.class, name, requirement, locked].hash
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class PluginEntry
|
|
90
|
+
attr_reader :id, :version, :config_hash
|
|
91
|
+
|
|
92
|
+
def initialize(id:, version:, config_hash: nil)
|
|
93
|
+
@id = id.to_s.dup.freeze
|
|
94
|
+
@version = version.to_s.dup.freeze
|
|
95
|
+
@config_hash = config_hash.nil? ? nil : config_hash.to_s.dup.freeze
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_h
|
|
100
|
+
{ "id" => id, "version" => version, "config_hash" => config_hash }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ==(other)
|
|
104
|
+
other.is_a?(PluginEntry) &&
|
|
105
|
+
other.id == id && other.version == version && other.config_hash == config_hash
|
|
106
|
+
end
|
|
107
|
+
alias eql? ==
|
|
108
|
+
|
|
109
|
+
def hash
|
|
110
|
+
[self.class, id, version, config_hash].hash
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class ConfigEntry
|
|
115
|
+
attr_reader :key, :value_hash
|
|
116
|
+
|
|
117
|
+
def initialize(key:, value_hash:)
|
|
118
|
+
@key = key.to_s.dup.freeze
|
|
119
|
+
@value_hash = value_hash.to_s.dup.freeze
|
|
120
|
+
freeze
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_h
|
|
124
|
+
{ "key" => key, "value_hash" => value_hash }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ==(other)
|
|
128
|
+
other.is_a?(ConfigEntry) && other.key == key && other.value_hash == value_hash
|
|
129
|
+
end
|
|
130
|
+
alias eql? ==
|
|
131
|
+
|
|
132
|
+
def hash
|
|
133
|
+
[self.class, key, value_hash].hash
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Raised when {.compose} encounters incompatible entries
|
|
138
|
+
# under the same key (file digest mismatch, gem-locked
|
|
139
|
+
# disagreement, …). Callers handle the exception by
|
|
140
|
+
# invalidating the cache slice rather than choosing one
|
|
141
|
+
# contribution silently.
|
|
142
|
+
class Conflict < StandardError; end
|
|
143
|
+
|
|
144
|
+
attr_reader :files, :gems, :plugins, :configs
|
|
145
|
+
|
|
146
|
+
def initialize(files: [], gems: [], plugins: [], configs: [])
|
|
147
|
+
@files = files.dup.freeze
|
|
148
|
+
@gems = gems.dup.freeze
|
|
149
|
+
@plugins = plugins.dup.freeze
|
|
150
|
+
@configs = configs.dup.freeze
|
|
151
|
+
freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# File-comparator strictness ordering. `:digest` is strictest
|
|
155
|
+
# (deterministic across machines); `:mtime` is cheaper but
|
|
156
|
+
# local; `:exists` is the weakest signal. When two
|
|
157
|
+
# contributors disagree on the comparator for the same
|
|
158
|
+
# `path`, the stricter one wins.
|
|
159
|
+
COMPARATOR_STRICTNESS = { digest: 2, mtime: 1, exists: 0 }.freeze
|
|
160
|
+
private_constant :COMPARATOR_STRICTNESS
|
|
161
|
+
|
|
162
|
+
# Composes any number of descriptors into a single descriptor
|
|
163
|
+
# whose slots are the union of the inputs' slots. Conflicts
|
|
164
|
+
# raise {Conflict}; idempotent contributions (same key, same
|
|
165
|
+
# value) collapse to a single entry.
|
|
166
|
+
def self.compose(*descriptors)
|
|
167
|
+
return new if descriptors.empty?
|
|
168
|
+
|
|
169
|
+
files = compose_files(descriptors.flat_map(&:files))
|
|
170
|
+
gems = compose_by_key(descriptors.flat_map(&:gems), :name)
|
|
171
|
+
plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
|
|
172
|
+
configs = compose_by_key(descriptors.flat_map(&:configs), :key)
|
|
173
|
+
new(files: files, gems: gems, plugins: plugins, configs: configs)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @param producer_id [String]
|
|
177
|
+
# @param params [Hash] inputs the producer was called with
|
|
178
|
+
# @return [String] hex SHA-256 cache key for the value
|
|
179
|
+
def cache_key_for(producer_id:, params: {})
|
|
180
|
+
payload = {
|
|
181
|
+
"schema_version" => SCHEMA_VERSION,
|
|
182
|
+
"producer_id" => producer_id.to_s,
|
|
183
|
+
"params" => self.class.canonicalize_value(params),
|
|
184
|
+
"descriptor" => to_canonical_hash
|
|
185
|
+
}
|
|
186
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Canonical UTF-8 JSON serialisation. Slots appear in
|
|
190
|
+
# lexicographic order; entries are sorted by their key field
|
|
191
|
+
# so two equivalent descriptors produce identical bytes.
|
|
192
|
+
def to_canonical_bytes
|
|
193
|
+
JSON.generate(to_canonical_hash).b
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_canonical_hash
|
|
197
|
+
{
|
|
198
|
+
"configs" => sort_entries(configs, "key").map(&:to_h),
|
|
199
|
+
"files" => sort_entries(files, "path").map(&:to_h),
|
|
200
|
+
"gems" => sort_entries(gems, "name").map(&:to_h),
|
|
201
|
+
"plugins" => sort_entries(plugins, "id").map(&:to_h)
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ==(other)
|
|
206
|
+
other.is_a?(Descriptor) &&
|
|
207
|
+
to_canonical_bytes == other.to_canonical_bytes
|
|
208
|
+
end
|
|
209
|
+
alias eql? ==
|
|
210
|
+
|
|
211
|
+
def hash
|
|
212
|
+
to_canonical_bytes.hash
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class << self
|
|
216
|
+
# Recursively coerces a Ruby value into a JSON-canonical
|
|
217
|
+
# structure: hash keys are stringified and sorted; arrays
|
|
218
|
+
# preserve order; symbols stringify; everything else is
|
|
219
|
+
# JSON-renderable.
|
|
220
|
+
def canonicalize_value(value)
|
|
221
|
+
case value
|
|
222
|
+
when Hash
|
|
223
|
+
value.to_a.map { |k, v| [k.to_s, canonicalize_value(v)] }.sort_by(&:first).to_h
|
|
224
|
+
when Array
|
|
225
|
+
value.map { |v| canonicalize_value(v) }
|
|
226
|
+
when Symbol
|
|
227
|
+
value.to_s
|
|
228
|
+
else
|
|
229
|
+
value
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def sort_entries(entries, key)
|
|
237
|
+
entries.sort_by { |e| e.to_h.fetch(key).to_s }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.compose_by_key(entries, key)
|
|
241
|
+
grouped = entries.group_by { |e| e.public_send(key) }
|
|
242
|
+
grouped.map do |_k, group|
|
|
243
|
+
unique = group.uniq
|
|
244
|
+
if unique.size == 1
|
|
245
|
+
unique.first
|
|
246
|
+
else
|
|
247
|
+
raise Conflict,
|
|
248
|
+
"cache descriptor conflict on #{key}=#{group.first.public_send(key).inspect}: " \
|
|
249
|
+
"got #{unique.size} incompatible entries"
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
private_class_method :compose_by_key
|
|
254
|
+
|
|
255
|
+
def self.compose_files(entries)
|
|
256
|
+
grouped = entries.group_by(&:path)
|
|
257
|
+
grouped.map do |path, group|
|
|
258
|
+
merge_file_group(path, group)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
private_class_method :compose_files
|
|
262
|
+
|
|
263
|
+
def self.merge_file_group(path, group)
|
|
264
|
+
strictest_rank = group.map { |e| COMPARATOR_STRICTNESS.fetch(e.comparator) }.max
|
|
265
|
+
strictest = group.select { |e| COMPARATOR_STRICTNESS.fetch(e.comparator) == strictest_rank }
|
|
266
|
+
values = strictest.map(&:value).uniq
|
|
267
|
+
unless values.size == 1
|
|
268
|
+
raise Conflict,
|
|
269
|
+
"cache descriptor conflict on file=#{path.inspect}: " \
|
|
270
|
+
"got #{values.size} disagreeing values under the stricter comparator"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
strictest.first
|
|
274
|
+
end
|
|
275
|
+
private_class_method :merge_file_group
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Cache
|
|
7
|
+
# Cache producer that materialises every RBS-declared constant
|
|
8
|
+
# to its translated `Rigor::Type` form and stores the result as
|
|
9
|
+
# a `Hash<String, Rigor::Type>` keyed by canonical constant name.
|
|
10
|
+
# This is the v0.0.8 first cached producer per ADR-6 § 7; it
|
|
11
|
+
# caches a post-translation artefact so the cache value is
|
|
12
|
+
# `Marshal`-clean (RBS-native objects carry `RBS::Location`,
|
|
13
|
+
# which lacks `_dump_data`).
|
|
14
|
+
#
|
|
15
|
+
# Cache descriptor:
|
|
16
|
+
#
|
|
17
|
+
# - `gems`: the `rbs` gem (with the locked version) so a gem
|
|
18
|
+
# upgrade invalidates the table — bundled core + stdlib
|
|
19
|
+
# signatures live inside the gem.
|
|
20
|
+
# - `files`: the digest of every `.rbs` file under the loader's
|
|
21
|
+
# `signature_paths` (project-supplied signatures that the
|
|
22
|
+
# gem's locked version cannot cover).
|
|
23
|
+
# - `configs`: the SHA-256 of the loader's libraries list so
|
|
24
|
+
# adding/removing a stdlib library invalidates.
|
|
25
|
+
class RbsConstantTable
|
|
26
|
+
PRODUCER_ID = "rbs.constant_type_table"
|
|
27
|
+
|
|
28
|
+
# @param loader [Rigor::Environment::RbsLoader]
|
|
29
|
+
# @param store [Rigor::Cache::Store]
|
|
30
|
+
# @return [Hash{String => Rigor::Type}]
|
|
31
|
+
def self.fetch(loader:, store:)
|
|
32
|
+
descriptor = build_descriptor(loader)
|
|
33
|
+
store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
|
|
34
|
+
compute(loader)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.build_descriptor(loader)
|
|
39
|
+
Descriptor.new(
|
|
40
|
+
gems: [rbs_gem_entry],
|
|
41
|
+
files: file_entries(loader),
|
|
42
|
+
configs: [libraries_entry(loader)]
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.compute(loader)
|
|
47
|
+
loader.constant_names.each_with_object({}) do |name, table|
|
|
48
|
+
translated = loader.constant_type(name)
|
|
49
|
+
table[name] = translated unless translated.nil?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.rbs_gem_entry
|
|
54
|
+
Descriptor::GemEntry.new(name: "rbs", requirement: ">= 0", locked: ::RBS::VERSION.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.file_entries(loader)
|
|
58
|
+
loader.signature_paths.flat_map do |root|
|
|
59
|
+
next [] unless root.directory?
|
|
60
|
+
|
|
61
|
+
Dir.glob(root.join("**", "*.rbs")).map do |path|
|
|
62
|
+
Descriptor::FileEntry.new(
|
|
63
|
+
path: path,
|
|
64
|
+
comparator: :digest,
|
|
65
|
+
value: Digest::SHA256.file(path).hexdigest
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.libraries_entry(loader)
|
|
72
|
+
sorted = loader.libraries.map(&:to_s).sort
|
|
73
|
+
Descriptor::ConfigEntry.new(
|
|
74
|
+
key: "rbs.libraries",
|
|
75
|
+
value_hash: Digest::SHA256.hexdigest(sorted.join("\n"))
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private_class_method :build_descriptor, :compute,
|
|
80
|
+
:rbs_gem_entry, :file_entries, :libraries_entry
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Cache
|
|
10
|
+
# Filesystem-backed cache store. Schema, layout, file format,
|
|
11
|
+
# atomicity, and locking are fixed by [ADR-6](../../../docs/adr/6-cache-persistence-backend.md);
|
|
12
|
+
# callers see the [`Rigor::Cache::Descriptor`](descriptor.rb)
|
|
13
|
+
# value object plus this class' `#fetch_or_compute` entry point
|
|
14
|
+
# and nothing else.
|
|
15
|
+
#
|
|
16
|
+
# Read failures (missing file, bad magic, format-version mismatch,
|
|
17
|
+
# corrupt SHA-256 trailer, unmarshal-able payload) are silently
|
|
18
|
+
# treated as cache misses; the producer block reruns and the
|
|
19
|
+
# next write replaces the bad entry. The trailing SHA-256 catches
|
|
20
|
+
# accidental corruption (partial writes, FS errors); it is **not**
|
|
21
|
+
# a security boundary, per ADR-2's trusted-gem trust model.
|
|
22
|
+
class Store # rubocop:disable Metrics/ClassLength
|
|
23
|
+
# Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte
|
|
24
|
+
# format version. Bumped on incompatible on-disk format changes
|
|
25
|
+
# (independent of {Descriptor::SCHEMA_VERSION}, which covers
|
|
26
|
+
# the descriptor schema rather than the byte layout).
|
|
27
|
+
HEADER = "RIGOR\x00\x01".b.freeze
|
|
28
|
+
|
|
29
|
+
VALID_PRODUCER_ID = /\A[a-z][a-z0-9._-]*\z/
|
|
30
|
+
|
|
31
|
+
def initialize(root:)
|
|
32
|
+
@root = root.to_s.dup.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :root
|
|
36
|
+
|
|
37
|
+
# Walks the on-disk cache rooted at `root` and reports a
|
|
38
|
+
# producer-level inventory. Used by `rigor check --cache-stats`
|
|
39
|
+
# to surface cache size and per-producer entry counts without
|
|
40
|
+
# depending on in-process counters (which only reflect the
|
|
41
|
+
# current run).
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash] `{ root:, schema_version:, total_entries:,
|
|
44
|
+
# total_bytes:, producers: [{ id:, entries:, bytes: }, ...] }`.
|
|
45
|
+
# When the root does not exist or has no schema-version
|
|
46
|
+
# marker, `schema_version` is nil and the producer list is
|
|
47
|
+
# empty.
|
|
48
|
+
def self.disk_inventory(root:)
|
|
49
|
+
root_s = root.to_s
|
|
50
|
+
marker = File.join(root_s, "schema_version.txt")
|
|
51
|
+
schema = File.file?(marker) ? File.read(marker).strip : nil
|
|
52
|
+
|
|
53
|
+
producers = collect_producers(root_s)
|
|
54
|
+
total_entries = producers.sum { |p| p[:entries] }
|
|
55
|
+
total_bytes = producers.sum { |p| p[:bytes] }
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
root: root_s,
|
|
59
|
+
schema_version: schema,
|
|
60
|
+
total_entries: total_entries,
|
|
61
|
+
total_bytes: total_bytes,
|
|
62
|
+
producers: producers
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.collect_producers(root)
|
|
67
|
+
return [] unless File.directory?(root)
|
|
68
|
+
|
|
69
|
+
Dir.children(root).sort.filter_map do |child|
|
|
70
|
+
subdir = File.join(root, child)
|
|
71
|
+
next nil unless File.directory?(subdir)
|
|
72
|
+
|
|
73
|
+
entries = Dir.glob(File.join(subdir, "**", "*.entry"))
|
|
74
|
+
next nil if entries.empty?
|
|
75
|
+
|
|
76
|
+
{ id: child, entries: entries.size, bytes: entries.sum { |e| File.size(e) } }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
private_class_method :collect_producers
|
|
80
|
+
|
|
81
|
+
# @param producer_id [String] stable cache namespace; only
|
|
82
|
+
# `[a-z][a-z0-9._-]*` is accepted.
|
|
83
|
+
# @param params [Hash] producer inputs; mixed into the cache key
|
|
84
|
+
# via {Descriptor#cache_key_for}.
|
|
85
|
+
# @param descriptor [Rigor::Cache::Descriptor] the invalidation
|
|
86
|
+
# descriptor for the value being cached.
|
|
87
|
+
# @yieldreturn the value to cache (must be `Marshal.dump`-able).
|
|
88
|
+
# @return the cached value (loaded from disk on hit; produced by
|
|
89
|
+
# the block on miss).
|
|
90
|
+
def fetch_or_compute(producer_id:, params:, descriptor:, &block)
|
|
91
|
+
validate_producer_id!(producer_id)
|
|
92
|
+
ensure_schema_version!
|
|
93
|
+
|
|
94
|
+
key = descriptor.cache_key_for(producer_id: producer_id, params: params)
|
|
95
|
+
path = entry_path(producer_id, key)
|
|
96
|
+
|
|
97
|
+
cached = read_entry(path)
|
|
98
|
+
return cached.value unless cached.nil?
|
|
99
|
+
|
|
100
|
+
value = block.call
|
|
101
|
+
write_entry(path, descriptor, value)
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
Entry = Data.define(:descriptor_bytes, :value)
|
|
108
|
+
private_constant :Entry
|
|
109
|
+
|
|
110
|
+
def validate_producer_id!(producer_id)
|
|
111
|
+
return if producer_id.is_a?(String) && producer_id.match?(VALID_PRODUCER_ID)
|
|
112
|
+
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"producer_id must match #{VALID_PRODUCER_ID.inspect}, got #{producer_id.inspect}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def entry_path(producer_id, key)
|
|
118
|
+
File.join(@root, producer_id, key[0, 2], "#{key[2..]}.entry")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Reads and validates one entry file. Any failure (missing,
|
|
122
|
+
# short, bad magic, bad version, bad checksum, unmarshal-able)
|
|
123
|
+
# returns nil so the caller treats it as a cache miss.
|
|
124
|
+
def read_entry(path)
|
|
125
|
+
return nil unless File.file?(path)
|
|
126
|
+
|
|
127
|
+
bytes = File.binread(path)
|
|
128
|
+
return nil unless envelope_valid?(bytes)
|
|
129
|
+
|
|
130
|
+
body = bytes.byteslice(HEADER.bytesize, bytes.bytesize - HEADER.bytesize - 32)
|
|
131
|
+
descriptor_bytes, value_bytes = parse_body(body)
|
|
132
|
+
return nil if descriptor_bytes.nil?
|
|
133
|
+
|
|
134
|
+
value = safe_marshal_load(value_bytes)
|
|
135
|
+
return nil if value.equal?(MARSHAL_LOAD_FAILED)
|
|
136
|
+
|
|
137
|
+
Entry.new(descriptor_bytes, value)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Validates the magic + format-version header and the trailing
|
|
141
|
+
# SHA-256 over everything before the trailer.
|
|
142
|
+
def envelope_valid?(bytes)
|
|
143
|
+
return false if bytes.bytesize < HEADER.bytesize + 32
|
|
144
|
+
return false unless bytes.byteslice(0, HEADER.bytesize) == HEADER
|
|
145
|
+
|
|
146
|
+
trailer = bytes.byteslice(bytes.bytesize - 32, 32)
|
|
147
|
+
Digest::SHA256.digest(bytes.byteslice(0, bytes.bytesize - 32)) == trailer
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Splits the body into (descriptor_bytes, value_bytes). Returns
|
|
151
|
+
# `[nil, nil]` on a malformed varint or length-overrun.
|
|
152
|
+
def parse_body(body)
|
|
153
|
+
offset = 0
|
|
154
|
+
descriptor_len, offset = read_varint(body, offset)
|
|
155
|
+
return [nil, nil] if descriptor_len.nil? || offset + descriptor_len > body.bytesize
|
|
156
|
+
|
|
157
|
+
descriptor_bytes = body.byteslice(offset, descriptor_len)
|
|
158
|
+
offset += descriptor_len
|
|
159
|
+
|
|
160
|
+
value_len, offset = read_varint(body, offset)
|
|
161
|
+
return [nil, nil] if value_len.nil? || offset + value_len != body.bytesize
|
|
162
|
+
|
|
163
|
+
value_bytes = body.byteslice(offset, value_len)
|
|
164
|
+
[descriptor_bytes, value_bytes]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
MARSHAL_LOAD_FAILED = Object.new.freeze
|
|
168
|
+
private_constant :MARSHAL_LOAD_FAILED
|
|
169
|
+
|
|
170
|
+
def safe_marshal_load(bytes)
|
|
171
|
+
Marshal.load(bytes) # rubocop:disable Security/MarshalLoad
|
|
172
|
+
rescue StandardError
|
|
173
|
+
MARSHAL_LOAD_FAILED
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def write_entry(path, descriptor, value)
|
|
177
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
178
|
+
|
|
179
|
+
descriptor_bytes = descriptor.to_canonical_bytes
|
|
180
|
+
value_bytes = Marshal.dump(value).b
|
|
181
|
+
|
|
182
|
+
body = +"".b
|
|
183
|
+
body << HEADER
|
|
184
|
+
write_varint(body, descriptor_bytes.bytesize)
|
|
185
|
+
body << descriptor_bytes
|
|
186
|
+
write_varint(body, value_bytes.bytesize)
|
|
187
|
+
body << value_bytes
|
|
188
|
+
body << Digest::SHA256.digest(body)
|
|
189
|
+
|
|
190
|
+
atomically_replace(path, body)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def atomically_replace(path, body)
|
|
194
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |lock_fd|
|
|
195
|
+
lock_fd.flock(File::LOCK_EX)
|
|
196
|
+
tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
|
|
197
|
+
File.open(tmp, "wb") do |f|
|
|
198
|
+
f.write(body)
|
|
199
|
+
f.fsync
|
|
200
|
+
end
|
|
201
|
+
File.rename(tmp, path)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ensure_schema_version!
|
|
206
|
+
FileUtils.mkdir_p(@root)
|
|
207
|
+
marker = File.join(@root, "schema_version.txt")
|
|
208
|
+
current = Descriptor::SCHEMA_VERSION.to_s
|
|
209
|
+
|
|
210
|
+
if File.file?(marker)
|
|
211
|
+
on_disk = File.read(marker).strip
|
|
212
|
+
return if on_disk == current
|
|
213
|
+
|
|
214
|
+
clear_cache_root!
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
FileUtils.mkdir_p(@root)
|
|
218
|
+
File.write(marker, "#{current}\n")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def clear_cache_root!
|
|
222
|
+
Dir.children(@root).each do |entry|
|
|
223
|
+
FileUtils.rm_rf(File.join(@root, entry))
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# LEB128 unsigned varint encoder/decoder. Lengths fit easily in
|
|
228
|
+
# five bytes (cap at 2^35); the cache layer never writes a value
|
|
229
|
+
# larger than that in practice.
|
|
230
|
+
def write_varint(bytes, value)
|
|
231
|
+
raise ArgumentError, "varint must be non-negative" if value.negative?
|
|
232
|
+
|
|
233
|
+
loop do
|
|
234
|
+
if value < 0x80
|
|
235
|
+
bytes << [value].pack("C")
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
bytes << [(value & 0x7F) | 0x80].pack("C")
|
|
240
|
+
value >>= 7
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def read_varint(bytes, offset)
|
|
245
|
+
result = 0
|
|
246
|
+
shift = 0
|
|
247
|
+
loop do
|
|
248
|
+
return [nil, offset] if offset >= bytes.bytesize
|
|
249
|
+
|
|
250
|
+
byte = bytes.getbyte(offset)
|
|
251
|
+
offset += 1
|
|
252
|
+
result |= (byte & 0x7F) << shift
|
|
253
|
+
return [result, offset] if byte < 0x80
|
|
254
|
+
|
|
255
|
+
shift += 7
|
|
256
|
+
return [nil, offset] if shift > 35
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
3
4
|
require "json"
|
|
4
5
|
require "optionparser"
|
|
5
6
|
require "yaml"
|
|
@@ -65,27 +66,75 @@ module Rigor
|
|
|
65
66
|
|
|
66
67
|
def run_check
|
|
67
68
|
require_relative "analysis/runner"
|
|
69
|
+
require_relative "cache/store"
|
|
68
70
|
|
|
71
|
+
options = parse_check_options
|
|
72
|
+
|
|
73
|
+
cache_root = ".rigor/cache"
|
|
74
|
+
handle_clear_cache(cache_root) if options.fetch(:clear_cache)
|
|
75
|
+
|
|
76
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
77
|
+
paths = @argv.empty? ? configuration.paths : @argv
|
|
78
|
+
result = Analysis::Runner.new(configuration: configuration, explain: options.fetch(:explain)).run(paths)
|
|
79
|
+
|
|
80
|
+
write_result(result, options.fetch(:format))
|
|
81
|
+
write_cache_stats(cache_root) if options.fetch(:cache_stats)
|
|
82
|
+
result.success? ? 0 : 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_check_options
|
|
69
86
|
options = {
|
|
70
87
|
config: Configuration::DEFAULT_PATH,
|
|
71
88
|
format: "text",
|
|
72
|
-
explain: false
|
|
89
|
+
explain: false,
|
|
90
|
+
cache_stats: false,
|
|
91
|
+
clear_cache: false
|
|
73
92
|
}
|
|
74
|
-
|
|
75
93
|
parser = OptionParser.new do |opts|
|
|
76
94
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
77
95
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
78
96
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
79
97
|
opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
|
|
98
|
+
opts.on("--cache-stats", "Print on-disk cache inventory at end of run") { options[:cache_stats] = true }
|
|
99
|
+
opts.on("--clear-cache", "Remove the .rigor/cache directory before running") { options[:clear_cache] = true }
|
|
80
100
|
end
|
|
81
101
|
parser.parse!(@argv)
|
|
102
|
+
options
|
|
103
|
+
end
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
def handle_clear_cache(cache_root)
|
|
106
|
+
if File.directory?(cache_root)
|
|
107
|
+
FileUtils.rm_rf(cache_root)
|
|
108
|
+
@out.puts("Cleared cache: #{cache_root}")
|
|
109
|
+
else
|
|
110
|
+
@out.puts("Cache already empty: #{cache_root}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
86
113
|
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
def write_cache_stats(cache_root)
|
|
115
|
+
inv = Cache::Store.disk_inventory(root: cache_root)
|
|
116
|
+
|
|
117
|
+
@out.puts("")
|
|
118
|
+
@out.puts("Cache (root: #{inv.fetch(:root)})")
|
|
119
|
+
schema = inv.fetch(:schema_version)
|
|
120
|
+
@out.puts(" schema_version: #{schema.nil? ? 'absent' : schema}")
|
|
121
|
+
if inv.fetch(:total_entries).zero?
|
|
122
|
+
@out.puts(" (empty)")
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@out.puts(" #{inv.fetch(:total_entries)} entries, #{format_bytes(inv.fetch(:total_bytes))}")
|
|
127
|
+
inv.fetch(:producers).each do |producer|
|
|
128
|
+
bytes = format_bytes(producer.fetch(:bytes))
|
|
129
|
+
@out.puts(" #{producer.fetch(:id)}: #{producer.fetch(:entries)} entries, #{bytes}")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def format_bytes(bytes)
|
|
134
|
+
return "#{bytes} B" if bytes < 1024
|
|
135
|
+
return format("%.1f KiB", bytes / 1024.0) if bytes < 1024 * 1024
|
|
136
|
+
|
|
137
|
+
format("%.1f MiB", bytes / (1024.0 * 1024.0))
|
|
89
138
|
end
|
|
90
139
|
|
|
91
140
|
def run_init
|
|
@@ -144,6 +144,17 @@ module Rigor
|
|
|
144
144
|
@hierarchy.class_ordering(lhs, rhs)
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
+
# @return [Array<String>] every RBS-declared constant name
|
|
148
|
+
# (top-level prefixed, e.g., `"::Math::PI"`) currently loaded
|
|
149
|
+
# into the environment. Used by the cache producer that
|
|
150
|
+
# materialises the constant-type table; ordinary callers
|
|
151
|
+
# should keep using {#constant_type} for point lookups.
|
|
152
|
+
def constant_names
|
|
153
|
+
env.constant_decls.keys.map(&:to_s)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
[]
|
|
156
|
+
end
|
|
157
|
+
|
|
147
158
|
# Slice A constant-value lookup. Returns the translated
|
|
148
159
|
# `Rigor::Type` for a non-class constant declaration
|
|
149
160
|
# (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or
|
data/lib/rigor/version.rb
CHANGED
data/lib/rigor.rb
CHANGED
|
@@ -19,6 +19,9 @@ require_relative "rigor/inference/closure_escape_analyzer"
|
|
|
19
19
|
require_relative "rigor/inference/statement_evaluator"
|
|
20
20
|
require_relative "rigor/scope"
|
|
21
21
|
require_relative "rigor/reflection"
|
|
22
|
+
require_relative "rigor/cache/descriptor"
|
|
23
|
+
require_relative "rigor/cache/store"
|
|
24
|
+
require_relative "rigor/cache/rbs_constant_table"
|
|
22
25
|
require_relative "rigor/source"
|
|
23
26
|
require_relative "rigor/inference/scope_indexer"
|
|
24
27
|
require_relative "rigor/inference/coverage_scanner"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rigortype
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rigor contributors
|
|
@@ -185,6 +185,9 @@ files:
|
|
|
185
185
|
- lib/rigor/ast.rb
|
|
186
186
|
- lib/rigor/ast/type_node.rb
|
|
187
187
|
- lib/rigor/builtins/imported_refinements.rb
|
|
188
|
+
- lib/rigor/cache/descriptor.rb
|
|
189
|
+
- lib/rigor/cache/rbs_constant_table.rb
|
|
190
|
+
- lib/rigor/cache/store.rb
|
|
188
191
|
- lib/rigor/cli.rb
|
|
189
192
|
- lib/rigor/cli/type_of_command.rb
|
|
190
193
|
- lib/rigor/cli/type_of_renderer.rb
|