rigortype 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +234 -22
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +199 -4
  13. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  16. data/lib/rigor/cache/rbs_constant_table.rb +15 -51
  17. data/lib/rigor/cache/rbs_descriptor.rb +55 -0
  18. data/lib/rigor/cache/rbs_environment.rb +52 -0
  19. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  21. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  22. data/lib/rigor/cache/store.rb +81 -15
  23. data/lib/rigor/cli.rb +45 -7
  24. data/lib/rigor/configuration/severity_profile.rb +109 -0
  25. data/lib/rigor/configuration.rb +110 -6
  26. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  27. data/lib/rigor/environment/rbs_loader.rb +220 -32
  28. data/lib/rigor/environment.rb +11 -2
  29. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  30. data/lib/rigor/flow_contribution/element.rb +53 -0
  31. data/lib/rigor/flow_contribution/fact.rb +88 -0
  32. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  33. data/lib/rigor/flow_contribution/merger.rb +275 -0
  34. data/lib/rigor/flow_contribution.rb +179 -0
  35. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  36. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  37. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  38. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  39. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  40. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  41. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  42. data/lib/rigor/inference/expression_typer.rb +110 -6
  43. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  44. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +173 -0
  45. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  46. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  47. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  48. data/lib/rigor/inference/narrowing.rb +134 -144
  49. data/lib/rigor/inference/scope_indexer.rb +75 -1
  50. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  51. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  52. data/lib/rigor/plugin/base.rb +241 -0
  53. data/lib/rigor/plugin/io_boundary.rb +102 -0
  54. data/lib/rigor/plugin/load_error.rb +23 -0
  55. data/lib/rigor/plugin/loader.rb +191 -0
  56. data/lib/rigor/plugin/manifest.rb +134 -0
  57. data/lib/rigor/plugin/registry.rb +50 -0
  58. data/lib/rigor/plugin/services.rb +65 -0
  59. data/lib/rigor/plugin/trust_policy.rb +99 -0
  60. data/lib/rigor/plugin.rb +61 -0
  61. data/lib/rigor/rbs_extended.rb +103 -0
  62. data/lib/rigor/reflection.rb +2 -2
  63. data/lib/rigor/type/combinator.rb +72 -0
  64. data/lib/rigor/type/refined.rb +50 -2
  65. data/lib/rigor/version.rb +1 -1
  66. data/lib/rigor.rb +13 -0
  67. data/sig/rigor/environment.rbs +7 -1
  68. data/sig/rigor/inference.rbs +1 -0
  69. data/sig/rigor/rbs_extended.rbs +2 -0
  70. data/sig/rigor/scope.rbs +1 -0
  71. data/sig/rigor/type.rbs +7 -0
  72. data/sig/rigor.rbs +3 -1
  73. metadata +38 -1
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Cache producer that materialises every loaded class's
8
+ # RBS-declared type-parameter names as a Marshal-clean
9
+ # `Hash<String, Array<Symbol>>` keyed by top-level-stripped
10
+ # class name (e.g. `"Array"` → `[:Elem]`, `"Hash"` →
11
+ # `[:K, :V]`). Producer id `"rbs.class_type_param_names"`.
12
+ #
13
+ # The dispatcher reads type-parameter names every time it
14
+ # builds a substitution map from a receiver's `type_args`
15
+ # into a method's return type — it is one of the hottest
16
+ # reflection lookups during analysis. Building one entry
17
+ # requires a full `RBS::DefinitionBuilder#build_instance`
18
+ # over that class, the same expensive operation
19
+ # {RbsClassAncestorTable} caches; the two producers share
20
+ # the build cost when populated together.
21
+ #
22
+ # Cache descriptor shape is shared with every other cache
23
+ # producer that depends on the RBS environment — see
24
+ # {RbsDescriptor.build}.
25
+ class RbsClassTypeParamNames
26
+ PRODUCER_ID = "rbs.class_type_param_names"
27
+
28
+ # @param loader [Rigor::Environment::RbsLoader]
29
+ # @param store [Rigor::Cache::Store]
30
+ # @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
+ def self.compute(loader)
39
+ table = {}
40
+ loader.each_known_class_name do |name|
41
+ key = name.delete_prefix("::")
42
+ params = type_params_for(loader, key)
43
+ table[key] = params unless params.nil?
44
+ end
45
+ table
46
+ end
47
+
48
+ def self.type_params_for(loader, class_name)
49
+ definition = loader.instance_definition(class_name)
50
+ return nil if definition.nil?
51
+
52
+ definition.type_params.dup.freeze
53
+ rescue ::RBS::BaseError
54
+ nil
55
+ end
56
+
57
+ private_class_method :compute, :type_params_for
58
+ end
59
+ end
60
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
3
+ require_relative "rbs_descriptor"
4
4
 
5
5
  module Rigor
6
6
  module Cache
@@ -12,16 +12,9 @@ module Rigor
12
12
  # `Marshal`-clean (RBS-native objects carry `RBS::Location`,
13
13
  # which lacks `_dump_data`).
14
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.
15
+ # Cache descriptor shape is shared with every other cache
16
+ # producer that depends on the RBS environment — see
17
+ # {RbsDescriptor.build} for the slot definitions.
25
18
  class RbsConstantTable
26
19
  PRODUCER_ID = "rbs.constant_type_table"
27
20
 
@@ -29,55 +22,26 @@ module Rigor
29
22
  # @param store [Rigor::Cache::Store]
30
23
  # @return [Hash{String => Rigor::Type}]
31
24
  def self.fetch(loader:, store:)
32
- descriptor = build_descriptor(loader)
25
+ descriptor = RbsDescriptor.build(loader)
33
26
  store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
34
27
  compute(loader)
35
28
  end
36
29
  end
37
30
 
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
31
  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?
32
+ table = {}
33
+ loader.each_constant_decl do |name, entry|
34
+ translated = Inference::RbsTypeTranslator.translate(entry.decl.type)
35
+ table[name] = translated unless translated.is_a?(Type::Bot)
36
+ rescue ::RBS::BaseError
37
+ # Skip entries whose RBS type fails to translate; the cache
38
+ # stays robust to a broken signature rather than corrupting
39
+ # the whole table. Analyzer-internal errors propagate.
50
40
  end
41
+ table
51
42
  end
52
43
 
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
44
+ private_class_method :compute
81
45
  end
82
46
  end
83
47
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "descriptor"
6
+
7
+ module Rigor
8
+ module Cache
9
+ # Shared descriptor builder for cache producers that depend on the
10
+ # RBS environment (constant table, known-class set, future
11
+ # Marshal-clean reflection artefacts). Every consumer attaches the
12
+ # same three slots, so factoring the construction here keeps the
13
+ # producers small and ensures invalidation behaves identically
14
+ # across them.
15
+ module RbsDescriptor
16
+ # @param loader [Rigor::Environment::RbsLoader]
17
+ # @return [Rigor::Cache::Descriptor]
18
+ def self.build(loader)
19
+ Descriptor.new(
20
+ gems: [rbs_gem_entry],
21
+ files: file_entries(loader),
22
+ configs: [libraries_entry(loader)]
23
+ )
24
+ end
25
+
26
+ def self.rbs_gem_entry
27
+ Descriptor::GemEntry.new(name: "rbs", requirement: ">= 0", locked: ::RBS::VERSION.to_s)
28
+ end
29
+
30
+ def self.file_entries(loader)
31
+ loader.signature_paths.flat_map do |root|
32
+ next [] unless root.directory?
33
+
34
+ Dir.glob(root.join("**", "*.rbs")).map do |path|
35
+ Descriptor::FileEntry.new(
36
+ path: path,
37
+ comparator: :digest,
38
+ value: Digest::SHA256.file(path).hexdigest
39
+ )
40
+ end
41
+ end
42
+ end
43
+
44
+ def self.libraries_entry(loader)
45
+ sorted = loader.libraries.map(&:to_s).sort
46
+ Descriptor::ConfigEntry.new(
47
+ key: "rbs.libraries",
48
+ value_hash: Digest::SHA256.hexdigest(sorted.join("\n"))
49
+ )
50
+ end
51
+
52
+ private_class_method :rbs_gem_entry, :file_entries, :libraries_entry
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+ require_relative "rbs_environment_marshal_patch"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # Cache producer that materialises the entire
9
+ # `RBS::Environment` (the loader's `build_env` result) and
10
+ # round-trips it through `Marshal` against the patched
11
+ # `RBS::Location` (see {RbsEnvironmentMarshalPatch}).
12
+ #
13
+ # Cold runs pay the full
14
+ # `RBS::EnvironmentLoader#load + RBS::Environment.from_loader
15
+ # + resolve_type_names` cost once; warm runs (and a separate
16
+ # loader sharing the same Store) load the marshalled blob and
17
+ # skip the parse / resolve stages entirely. The
18
+ # `RbsConstantTable`, `RbsKnownClassNames`,
19
+ # `RbsClassAncestorTable`, and `RbsClassTypeParamNames`
20
+ # caches still live alongside this producer — their cached
21
+ # values are reached without re-touching env, but when an
22
+ # uncached lookup happens (`instance_method`,
23
+ # `singleton_method`, …) the env produced here is what
24
+ # answers it.
25
+ #
26
+ # Cache descriptor shape is shared with every other cache
27
+ # producer that depends on the RBS environment — see
28
+ # {RbsDescriptor.build}.
29
+ class RbsEnvironment
30
+ PRODUCER_ID = "rbs.environment"
31
+
32
+ # @param loader [Rigor::Environment::RbsLoader]
33
+ # @param store [Rigor::Cache::Store]
34
+ # @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
+ def self.compute(loader)
43
+ Rigor::Environment::RbsLoader.build_env_for(
44
+ libraries: loader.libraries,
45
+ signature_paths: loader.signature_paths
46
+ )
47
+ end
48
+
49
+ private_class_method :compute
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbs"
4
+
5
+ # Adds `_dump` / `_load` to {RBS::Location} so an
6
+ # `RBS::Environment` (and its transitive AST nodes, all of
7
+ # which carry Locations) round-trips through `Marshal`. The
8
+ # rbs gem's C-extension `RBS::Location` ships without the
9
+ # Marshal hooks; until rbs grows them upstream this patch is
10
+ # the minimal monkey-patch the v0.0.9 RBS::Environment cache
11
+ # relies on.
12
+ #
13
+ # Patch policy (purely additive):
14
+ #
15
+ # - `_dump` returns an empty string. The cached env loses
16
+ # per-node source-position info, but Rigor does not consult
17
+ # `RBS::Location` from any analysis code path (every
18
+ # diagnostic uses Prism's own location), so the loss is
19
+ # inert in practice.
20
+ # - `_load` reconstructs a sentinel Location backed by an
21
+ # empty `<cached>` Buffer. Code paths that DID consult
22
+ # Location after a cache hit see a benign zero-range value
23
+ # rather than crashing.
24
+ #
25
+ # Idempotent: the guard checks `method_defined?(:_dump)` so
26
+ # requiring this file twice (or against an upstream rbs that
27
+ # adds Marshal hooks itself) is a no-op.
28
+ module RBS
29
+ class Location
30
+ unless method_defined?(:_dump)
31
+ def _dump(_)
32
+ ""
33
+ end
34
+
35
+ def self._load(_)
36
+ new(buffer: ::RBS::Buffer.new(name: "<cached>", content: ""), start_pos: 0, end_pos: 0)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+ require_relative "rbs_environment_marshal_patch"
5
+
6
+ module Rigor
7
+ module Cache
8
+ # Cache producer that materialises the full
9
+ # `Hash<String, RBS::Definition>` for instance-side class
10
+ # definitions in the RBS environment, in a single cache
11
+ # entry. Mirrors the {RbsConstantTable} layout.
12
+ #
13
+ # ADR-7 § "Slice 6-D" carry-over and dogfooding feedback:
14
+ # the earlier per-class cache layout (one entry per class,
15
+ # ~1300 files) made warm runs *slower* than `--no-cache`
16
+ # because each `instance_definition` call paid disk-open +
17
+ # `Marshal.load` overhead and the in-memory
18
+ # `RBS::DefinitionBuilder.build_instance` was actually fast
19
+ # given a cached `RBS::Environment`. The single-blob layout
20
+ # collapses that to one `Marshal.load` per process; warm runs
21
+ # now match `--no-cache` timing while preserving the
22
+ # cross-process invalidation story.
23
+ #
24
+ # Marshal-cleanness of `RBS::Definition` is enabled by the
25
+ # v0.0.9 C2 `RBS::Location` patch.
26
+ class RbsInstanceDefinitions
27
+ PRODUCER_ID = "rbs.instance_definitions"
28
+
29
+ # @param loader [Rigor::Environment::RbsLoader]
30
+ # @param store [Rigor::Cache::Store]
31
+ # @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
+ def self.compute(loader)
40
+ table = {}
41
+ loader.each_known_class_name do |name|
42
+ definition = loader.uncached_instance_definition(name)
43
+ table[name] = definition if definition
44
+ end
45
+ table
46
+ end
47
+
48
+ private_class_method :compute
49
+ end
50
+
51
+ # Singleton-side equivalent of {RbsInstanceDefinitions}.
52
+ # Caches the full `Hash<String, RBS::Definition>` for the
53
+ # singleton class of every RBS-known class.
54
+ class RbsSingletonDefinitions
55
+ PRODUCER_ID = "rbs.singleton_definitions"
56
+
57
+ # @param loader [Rigor::Environment::RbsLoader]
58
+ # @param store [Rigor::Cache::Store]
59
+ # @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
+ def self.compute(loader)
68
+ table = {}
69
+ loader.each_known_class_name do |name|
70
+ definition = loader.uncached_singleton_definition(name)
71
+ table[name] = definition if definition
72
+ end
73
+ table
74
+ end
75
+
76
+ private_class_method :compute
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rbs_descriptor"
4
+
5
+ module Rigor
6
+ module Cache
7
+ # Cache producer that materialises the set of every RBS-declared
8
+ # class / module / alias name (top-level prefixed, e.g.
9
+ # `"::Math"`) currently loaded into the environment. Marshal-
10
+ # clean — the cache value is a `Set<String>`.
11
+ #
12
+ # The set lets `RbsLoader#class_known?` answer point lookups in
13
+ # O(1) without re-parsing the name on each call, and lets a warm
14
+ # process skip the env walk entirely (the env still has to be
15
+ # built to enumerate decls on cold misses; subsequent processes
16
+ # sharing the Store load the set straight from disk).
17
+ #
18
+ # Cache descriptor shape is shared with {RbsConstantTable} via
19
+ # {RbsDescriptor.build}; a single signature change or rbs gem
20
+ # bump invalidates both producers in lockstep.
21
+ class RbsKnownClassNames
22
+ PRODUCER_ID = "rbs.known_class_names"
23
+
24
+ # @param loader [Rigor::Environment::RbsLoader]
25
+ # @param store [Rigor::Cache::Store]
26
+ # @return [Set<String>]
27
+ def self.fetch(loader:, store:)
28
+ descriptor = RbsDescriptor.build(loader)
29
+ store.fetch_or_compute(producer_id: PRODUCER_ID, params: {}, descriptor: descriptor) do
30
+ compute(loader)
31
+ end
32
+ end
33
+
34
+ def self.compute(loader)
35
+ names = Set.new
36
+ loader.each_known_class_name { |name| names << name }
37
+ names
38
+ end
39
+
40
+ private_class_method :compute
41
+ end
42
+ end
43
+ end
@@ -5,6 +5,8 @@ require "fileutils"
5
5
  require "json"
6
6
  require "securerandom"
7
7
 
8
+ require_relative "descriptor"
9
+
8
10
  module Rigor
9
11
  module Cache
10
12
  # Filesystem-backed cache store. Schema, layout, file format,
@@ -30,10 +32,27 @@ module Rigor
30
32
 
31
33
  def initialize(root:)
32
34
  @root = root.to_s.dup.freeze
35
+ @hits = 0
36
+ @misses = 0
37
+ @writes = 0
38
+ @by_producer = Hash.new { |h, k| h[k] = { hits: 0, misses: 0, writes: 0 } }
33
39
  end
34
40
 
35
41
  attr_reader :root
36
42
 
43
+ # Returns a frozen snapshot of this Store's per-run hit / miss /
44
+ # write counters. The bookkeeping is in-memory only — every new
45
+ # `Store.new` starts at zero — so the counters reflect activity
46
+ # against this specific instance rather than the on-disk cache
47
+ # state. Disk-level state is reported separately by
48
+ # {.disk_inventory}.
49
+ #
50
+ # @return [Hash] `{ hits:, misses:, writes:, by_producer: { id => { hits:, misses:, writes: } } }`
51
+ def stats
52
+ per_producer = @by_producer.transform_values { |counts| counts.dup.freeze }.freeze
53
+ { hits: @hits, misses: @misses, writes: @writes, by_producer: per_producer }.freeze
54
+ end
55
+
37
56
  # Walks the on-disk cache rooted at `root` and reports a
38
57
  # producer-level inventory. Used by `rigor check --cache-stats`
39
58
  # to surface cache size and per-producer entry counts without
@@ -84,21 +103,43 @@ module Rigor
84
103
  # via {Descriptor#cache_key_for}.
85
104
  # @param descriptor [Rigor::Cache::Descriptor] the invalidation
86
105
  # descriptor for the value being cached.
87
- # @yieldreturn the value to cache (must be `Marshal.dump`-able).
106
+ # @param serialize [#call, nil] optional callable that turns the
107
+ # producer's return value into a binary `String`. Defaults to
108
+ # `Marshal.dump(value).b`. Producers whose return values are
109
+ # not `Marshal`-clean (RBS-native objects with `RBS::Location`
110
+ # members, raw `IO`, …) MUST provide a serialiser. The pair
111
+ # `(serialize, deserialize)` MUST round-trip — a producer that
112
+ # reads with one strategy and writes with another corrupts
113
+ # its own cache slice.
114
+ # @param deserialize [#call, nil] optional callable that turns
115
+ # bytes back into the producer's value. Defaults to
116
+ # `Marshal.load`. Any exception (`StandardError`) raised by
117
+ # the deserialiser is treated as a cache miss — the entry is
118
+ # considered corrupt, the producer block reruns, and the
119
+ # next write overwrites it. This is consistent with the
120
+ # fault-tolerance contract for the default `Marshal.load`
121
+ # path.
122
+ # @yieldreturn the value to cache.
88
123
  # @return the cached value (loaded from disk on hit; produced by
89
124
  # the block on miss).
90
- def fetch_or_compute(producer_id:, params:, descriptor:, &block)
125
+ def fetch_or_compute(producer_id:, params:, descriptor:,
126
+ serialize: nil, deserialize: nil, &block)
91
127
  validate_producer_id!(producer_id)
92
128
  ensure_schema_version!
93
129
 
94
130
  key = descriptor.cache_key_for(producer_id: producer_id, params: params)
95
131
  path = entry_path(producer_id, key)
96
132
 
97
- cached = read_entry(path)
98
- return cached.value unless cached.nil?
133
+ cached = read_entry(path, deserialize: deserialize)
134
+ unless cached.nil?
135
+ record(:hits, producer_id)
136
+ return cached.value
137
+ end
99
138
 
139
+ record(:misses, producer_id)
100
140
  value = block.call
101
- write_entry(path, descriptor, value)
141
+ write_entry(path, descriptor, value, serialize: serialize)
142
+ record(:writes, producer_id)
102
143
  value
103
144
  end
104
145
 
@@ -107,6 +148,15 @@ module Rigor
107
148
  Entry = Data.define(:descriptor_bytes, :value)
108
149
  private_constant :Entry
109
150
 
151
+ def record(counter, producer_id)
152
+ case counter
153
+ when :hits then @hits += 1
154
+ when :misses then @misses += 1
155
+ when :writes then @writes += 1
156
+ end
157
+ @by_producer[producer_id][counter] += 1
158
+ end
159
+
110
160
  def validate_producer_id!(producer_id)
111
161
  return if producer_id.is_a?(String) && producer_id.match?(VALID_PRODUCER_ID)
112
162
 
@@ -121,7 +171,7 @@ module Rigor
121
171
  # Reads and validates one entry file. Any failure (missing,
122
172
  # short, bad magic, bad version, bad checksum, unmarshal-able)
123
173
  # returns nil so the caller treats it as a cache miss.
124
- def read_entry(path)
174
+ def read_entry(path, deserialize: nil)
125
175
  return nil unless File.file?(path)
126
176
 
127
177
  bytes = File.binread(path)
@@ -131,8 +181,8 @@ module Rigor
131
181
  descriptor_bytes, value_bytes = parse_body(body)
132
182
  return nil if descriptor_bytes.nil?
133
183
 
134
- value = safe_marshal_load(value_bytes)
135
- return nil if value.equal?(MARSHAL_LOAD_FAILED)
184
+ value = safe_load(value_bytes, deserialize)
185
+ return nil if value.equal?(LOAD_FAILED)
136
186
 
137
187
  Entry.new(descriptor_bytes, value)
138
188
  end
@@ -164,20 +214,24 @@ module Rigor
164
214
  [descriptor_bytes, value_bytes]
165
215
  end
166
216
 
167
- MARSHAL_LOAD_FAILED = Object.new.freeze
168
- private_constant :MARSHAL_LOAD_FAILED
217
+ LOAD_FAILED = Object.new.freeze
218
+ private_constant :LOAD_FAILED
169
219
 
170
- def safe_marshal_load(bytes)
171
- Marshal.load(bytes) # rubocop:disable Security/MarshalLoad
220
+ def safe_load(bytes, deserialize)
221
+ if deserialize
222
+ deserialize.call(bytes)
223
+ else
224
+ Marshal.load(bytes) # rubocop:disable Security/MarshalLoad
225
+ end
172
226
  rescue StandardError
173
- MARSHAL_LOAD_FAILED
227
+ LOAD_FAILED
174
228
  end
175
229
 
176
- def write_entry(path, descriptor, value)
230
+ def write_entry(path, descriptor, value, serialize: nil)
177
231
  FileUtils.mkdir_p(File.dirname(path))
178
232
 
179
233
  descriptor_bytes = descriptor.to_canonical_bytes
180
- value_bytes = Marshal.dump(value).b
234
+ value_bytes = serialize_value(value, serialize)
181
235
 
182
236
  body = +"".b
183
237
  body << HEADER
@@ -190,6 +244,18 @@ module Rigor
190
244
  atomically_replace(path, body)
191
245
  end
192
246
 
247
+ def serialize_value(value, serialize)
248
+ return Marshal.dump(value).b if serialize.nil?
249
+
250
+ bytes = serialize.call(value)
251
+ unless bytes.is_a?(String)
252
+ raise TypeError,
253
+ "custom serialize must return a String, got #{bytes.class}"
254
+ end
255
+
256
+ bytes.b
257
+ end
258
+
193
259
  def atomically_replace(path, body)
194
260
  File.open(path, File::RDWR | File::CREAT, 0o644) do |lock_fd|
195
261
  lock_fd.flock(File::LOCK_EX)