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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0aa63fadd57282a307bf4664cce576c1b955617b6643c64d436827595a751ccb
4
- data.tar.gz: 4e7cc6e58c5fcb45eb201182cbb87f723a558972334add650aecdeb80ed7c502
3
+ metadata.gz: 38aa66f97f5bed742a36c156ceee7f13d3181597b74c32329b693b3c219852b3
4
+ data.tar.gz: 69af0ccbf42c2b890b78a558d574b49293ff6b4880875568137098624e66d3f8
5
5
  SHA512:
6
- metadata.gz: 9972f4ac5258340ab534ce5bd3aad4dfda7f23e080013f0f3fdc96d4ea0fb45c458cfa3c68e14b5e2b7ffdf273e7fa81f76d7d80761cd0a5b3b6291f903a5157
7
- data.tar.gz: 3009bf4ab97ee163ec05fc262bbdc130dc5e5ebf877bea9b1e220f0607251d96bf81ad10e1f6505d2f2999e8f12bb6bb823577dd142645295e2fdc2fc7d29fe2
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
- attr_reader :path, :line, :column, :message, :severity, :rule
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
- configuration = Configuration.load(options.fetch(:config))
84
- paths = @argv.empty? ? configuration.paths : @argv
85
- result = Analysis::Runner.new(configuration: configuration, explain: options.fetch(:explain)).run(paths)
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
- write_result(result, options.fetch(:format))
88
- result.success? ? 0 : 1
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.0.7"
4
+ VERSION = "0.0.8"
5
5
  end
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.7
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