rigortype 0.4.0 → 0.4.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -3
  3. data/docs/handbook/08-understanding-errors.md +3 -2
  4. data/docs/install.md +87 -15
  5. data/docs/manual/02-cli-reference.md +2 -2
  6. data/docs/manual/03-configuration.md +5 -0
  7. data/docs/manual/08-skills.md +4 -3
  8. data/docs/manual/12-caching.md +43 -4
  9. data/docs/manual/14-rails-quickstart.md +19 -11
  10. data/lib/rigor/analysis/check_rules/source_arity.rb +163 -59
  11. data/lib/rigor/analysis/check_rules.rb +118 -87
  12. data/lib/rigor/analysis/dependency_recorder.rb +200 -18
  13. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +266 -0
  14. data/lib/rigor/analysis/incremental_run_slot.rb +171 -0
  15. data/lib/rigor/analysis/incremental_session.rb +273 -49
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +24 -13
  17. data/lib/rigor/analysis/reachability/scan_cache.rb +2 -1
  18. data/lib/rigor/analysis/run_cache_key.rb +27 -8
  19. data/lib/rigor/analysis/run_stats.rb +2 -2
  20. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +1 -1
  21. data/lib/rigor/analysis/runner/pool_coordinator.rb +2 -2
  22. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -13
  23. data/lib/rigor/analysis/runner.rb +337 -46
  24. data/lib/rigor/analysis/shadow_harness.rb +27 -0
  25. data/lib/rigor/analysis/source_rbs_gate.rb +157 -0
  26. data/lib/rigor/analysis/template_unit_paths.rb +6 -4
  27. data/lib/rigor/analysis/template_units.rb +15 -5
  28. data/lib/rigor/analysis/worker_session.rb +4 -3
  29. data/lib/rigor/cache/descriptor.rb +40 -1
  30. data/lib/rigor/cache/file_digest.rb +28 -0
  31. data/lib/rigor/cache/incremental_snapshot.rb +70 -8
  32. data/lib/rigor/cache/store.rb +63 -3
  33. data/lib/rigor/cache/value_digest.rb +325 -0
  34. data/lib/rigor/cli/check_command.rb +83 -13
  35. data/lib/rigor/cli/doctor_command.rb +1 -1
  36. data/lib/rigor/cli/effects_report.rb +2 -2
  37. data/lib/rigor/cli/unused_command.rb +3 -3
  38. data/lib/rigor/cli.rb +1 -1
  39. data/lib/rigor/config_audit.rb +3 -2
  40. data/lib/rigor/config_path.rb +29 -0
  41. data/lib/rigor/configuration/severity_profile.rb +5 -0
  42. data/lib/rigor/configuration.rb +6 -5
  43. data/lib/rigor/effects/plugin_facts.rb +6 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -3
  45. data/lib/rigor/environment/lockfile_resolver.rb +10 -1
  46. data/lib/rigor/environment/rbs_collection_discovery.rb +10 -1
  47. data/lib/rigor/environment/rbs_loader.rb +1 -1
  48. data/lib/rigor/environment/source_rbs_synthesis.rb +147 -0
  49. data/lib/rigor/environment.rb +13 -70
  50. data/lib/rigor/inference/block_parameter_binder.rb +29 -13
  51. data/lib/rigor/inference/captured_locals.rb +7 -1
  52. data/lib/rigor/inference/declaration_walk/context.rb +195 -0
  53. data/lib/rigor/inference/declaration_walk/errors.rb +18 -0
  54. data/lib/rigor/inference/declaration_walk/shadow.rb +147 -0
  55. data/lib/rigor/inference/declaration_walk/traversal.rb +441 -0
  56. data/lib/rigor/inference/declaration_walk.rb +7 -0
  57. data/lib/rigor/inference/def_node_resolver.rb +3 -2
  58. data/lib/rigor/inference/definer_resolution.rb +266 -0
  59. data/lib/rigor/inference/expression_typer.rb +136 -45
  60. data/lib/rigor/inference/external_ancestor_resolution.rb +13 -16
  61. data/lib/rigor/inference/guard_rebinding.rb +53 -12
  62. data/lib/rigor/inference/macro_block_self_type.rb +14 -2
  63. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +46 -26
  64. data/lib/rigor/inference/module_function_state.rb +213 -0
  65. data/lib/rigor/inference/multi_target_binder.rb +21 -16
  66. data/lib/rigor/inference/narrowing.rb +147 -17
  67. data/lib/rigor/inference/parameter_inference_collector.rb +10 -9
  68. data/lib/rigor/inference/scope_indexer/certainty.rb +176 -0
  69. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +35 -0
  70. data/lib/rigor/inference/scope_indexer/def_nestings_collector.rb +47 -0
  71. data/lib/rigor/inference/scope_indexer/layered_def_nestings.rb +36 -0
  72. data/lib/rigor/inference/scope_indexer/member_layouts_collector.rb +61 -0
  73. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +130 -0
  74. data/lib/rigor/inference/scope_indexer/superclasses_collector.rb +64 -0
  75. data/lib/rigor/inference/scope_indexer.rb +1443 -295
  76. data/lib/rigor/inference/singleton_hook_decline.rb +253 -0
  77. data/lib/rigor/inference/statement_evaluator.rb +80 -14
  78. data/lib/rigor/plugin/base.rb +28 -8
  79. data/lib/rigor/plugin/io_boundary.rb +82 -4
  80. data/lib/rigor/plugin/isolation.rb +1 -1
  81. data/lib/rigor/plugin/trust_policy.rb +3 -3
  82. data/lib/rigor/protection/discovery_seed.rb +18 -9
  83. data/lib/rigor/protection/mutation_cache.rb +3 -3
  84. data/lib/rigor/reflection/constant_ancestors.rb +96 -47
  85. data/lib/rigor/reflection/constant_path.rb +22 -7
  86. data/lib/rigor/reflection.rb +2 -3
  87. data/lib/rigor/scope/discovery_index.rb +218 -2
  88. data/lib/rigor/scope/resolution_chain/relevance.rb +165 -0
  89. data/lib/rigor/scope/resolution_chain.rb +884 -0
  90. data/lib/rigor/scope.rb +295 -322
  91. data/lib/rigor/sig_gen/alias_index.rb +1 -1
  92. data/lib/rigor/sig_gen/effect_annotation.rb +2 -2
  93. data/lib/rigor/sig_gen/generator.rb +22 -16
  94. data/lib/rigor/type/combinator.rb +25 -1
  95. data/lib/rigor/version.rb +1 -1
  96. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +10 -0
  97. data/sig/rigor/environment.rbs +2 -0
  98. data/sig/rigor/plugin/io_boundary.rbs +1 -0
  99. data/sig/rigor/scope.rbs +43 -1
  100. data/skills/rigor-project-init/SKILL.md +37 -11
  101. data/skills/rigor-project-init/references/02-configure.md +7 -2
  102. metadata +25 -1
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ # Issue #1536 (ADR-89 WD1 amendment) — what the incremental session knows about the RBS that source-RBS
8
+ # synthesizers derive from each file's comments. The declaration gates read a file's Ruby side only; a
9
+ # synthesizer's output reaches the environment as a `virtual:` buffer, and a reader of that RBS records no
10
+ # edge back to the file, so the session asks this gate one question before deciding a closure: did the
11
+ # edit move any synthesized output? A yes re-analyses the whole project.
12
+ #
13
+ # Each seed bundle carries its file's {Environment::SourceRbsSynthesis.digest} under {DIGEST_KEY}. The
14
+ # gate writes it ({#stamp}), not the scope indexer that builds the rest of the bundle: the digest needs a
15
+ # plugin registry, which discovery never sees. A stamp must describe the synthesized RBS the cached
16
+ # diagnostics of the file's readers were computed under; where that cannot be vouched for, it is nil, which
17
+ # reads as moved.
18
+ #
19
+ # The synthesizers are read from a registry the gate loads for itself, WITHOUT `#prepare`, because the
20
+ # closure is decided before the recheck runner (which loads and prepares its own) exists. That is exact
21
+ # only for a synthesizer reachable without `#prepare`, so {#verify} compares the two sets after every run:
22
+ # a plugin that builds its synthesizer in `#prepare` leaves the gate UNTRUSTED for the rest of the
23
+ # session — every stored digest reads as unknown, and every edit re-analyses the whole project.
24
+ class SourceRbsGate
25
+ DIGEST_KEY = :source_rbs_digest
26
+
27
+ # The paths {#stamp} last stored as unknown because the bytes its bundle was built from are not the
28
+ # bytes the closure was decided on. The session forgets their recorded content digests, so the next run
29
+ # detects them as changed and, reading the unknown stamp, re-analyses the project.
30
+ attr_reader :unbound
31
+
32
+ def initialize(configuration:, cache_store:, plugin_requirer:)
33
+ @configuration = configuration
34
+ @cache_store = cache_store
35
+ @plugin_requirer = plugin_requirer
36
+ @synthesizers = nil
37
+ @readings = nil
38
+ @unbound = Set.new.freeze
39
+ @untrusted = false
40
+ end
41
+
42
+ # True once a run's prepared registry declared a different synthesizer set from the one the gate
43
+ # digests with. Sticky for the session: the configuration, and so the plugin set, is fixed for its life.
44
+ def untrusted?
45
+ @untrusted
46
+ end
47
+
48
+ # Whether this edit moved what any synthesizer contributes: a changed file whose digest differs from
49
+ # the one on its snapshot bundle (or either is unknown), an added file that contributes anything, or a
50
+ # removed file that did. Decided from the synthesizers' output, never from which plugins `plugins:`
51
+ # names. The readings taken here are kept for {#stamp}.
52
+ def moved?(bundles, changed, added, removed)
53
+ return true if @untrusted && [changed, added, removed].any?(&:any?)
54
+
55
+ @readings = (changed + added).to_h { |path| [path, reading(path)] }
56
+ none = Environment::SourceRbsSynthesis::NO_CONTRIBUTION
57
+ changed.any? { |path| !unmoved?(bundles, path) } ||
58
+ added.any? { |path| @readings.fetch(path).last != none } ||
59
+ removed.any? { |path| stored(bundles, path) != none }
60
+ end
61
+
62
+ # Stamps every bundle that needs a digest: one the runner built this run (a baseline's all, a recheck's
63
+ # re-walked ones), and one carrying nil that this run can now vouch for. A bundle the runner reused keeps
64
+ # the digest it carries, which stays exact because a bundle is reused only for byte-identical content.
65
+ #
66
+ # A digest is vouched for only by a reading of the bytes the bundle was built from: the reading
67
+ # {#moved?} took when the closure was decided, or, when `whole_project` (every file was just
68
+ # re-analysed), one taken now. Anything else is stamped nil and listed in {#unbound}; a file saved after
69
+ # the closure was decided is never re-read, because its readers' cached answers predate that save. An
70
+ # untrusted gate stamps every bundle unknown.
71
+ def stamp(bundles, whole_project: false)
72
+ readings = @readings || {}
73
+ @readings = nil
74
+ @unbound = Set.new
75
+ return distrust(bundles) if @untrusted
76
+
77
+ targets = bundles.select { |path, bundle| stamp?(bundle, whole_project || readings.key?(path)) }
78
+ return bundles if targets.empty?
79
+
80
+ stamped = bundles.dup
81
+ targets.each do |path, bundle|
82
+ stamped[path] = bundle.merge(DIGEST_KEY => bound_digest(path, bundle, readings[path], whole_project))
83
+ end
84
+ stamped
85
+ end
86
+
87
+ # Compares, by plugin id and order, the synthesizers the gate digests with against those of
88
+ # `prepared_registry` — the registry the run's environment was built from, after `#prepare` (nil when
89
+ # it could not be built, which also distrusts). Runs after every run, a run that digested nothing
90
+ # included: a stamp written by an earlier process from the other set would otherwise be trusted. On a
91
+ # mismatch the gate turns untrusted and every bundle is stamped unknown. Returns the bundles to keep.
92
+ def verify(prepared_registry, bundles)
93
+ return bundles if synthesizer_ids(prepared_registry&.source_rbs_synthesizers) == synthesizer_ids(synthesizers)
94
+
95
+ @untrusted = true
96
+ distrust(bundles)
97
+ end
98
+
99
+ private
100
+
101
+ def unmoved?(bundles, path)
102
+ before = stored(bundles, path)
103
+ !before.nil? && before == @readings.fetch(path).last
104
+ end
105
+
106
+ def stored(bundles, path)
107
+ bundles[path]&.fetch(DIGEST_KEY, nil)
108
+ end
109
+
110
+ def distrust(bundles)
111
+ bundles.transform_values { |bundle| bundle.merge(DIGEST_KEY => nil) }
112
+ end
113
+
114
+ # A bundle without a digest always needs one; one carrying nil is re-stamped only when this run can
115
+ # vouch for it, so an unknown stamp left by a distrust or an unbound read clears on the file's next edit
116
+ # or the next whole-project run.
117
+ def stamp?(bundle, vouched)
118
+ !bundle.key?(DIGEST_KEY) || (bundle[DIGEST_KEY].nil? && vouched)
119
+ end
120
+
121
+ # The digest for `bundle` when a reading vouches for the bytes it was built from, else nil (and the path
122
+ # joins {#unbound}).
123
+ def bound_digest(path, bundle, reading, whole_project)
124
+ reading ||= reading(path) if whole_project
125
+ return reading.last if reading && reading.first == bundle[:digest]
126
+
127
+ @unbound << path
128
+ nil
129
+ end
130
+
131
+ # `[content SHA-256, digest]`, the SHA nil unless the file read the same before and after the digest was
132
+ # taken, so the pair can be matched against a bundle's content digest.
133
+ def reading(path)
134
+ before = content_sha(path)
135
+ digest = Environment::SourceRbsSynthesis.digest(synthesizers, path, @cache_store)
136
+ [before && before == content_sha(path) ? before : nil, digest]
137
+ end
138
+
139
+ def content_sha(path)
140
+ Digest::SHA256.file(path).hexdigest
141
+ rescue SystemCallError
142
+ nil
143
+ end
144
+
145
+ def synthesizers
146
+ @synthesizers ||= Runner::ProjectPrePasses.new(
147
+ configuration: @configuration, cache_store: @cache_store, buffer: nil,
148
+ plugin_requirer: @plugin_requirer, pool_mode: -> { false }
149
+ ).prepared_registry(prepare: false).source_rbs_synthesizers
150
+ end
151
+
152
+ def synthesizer_ids(pairs)
153
+ pairs&.map { |plugin, _| plugin.manifest.id.to_s }
154
+ end
155
+ end
156
+ end
157
+ end
@@ -21,13 +21,15 @@ module Rigor
21
21
  # how an LSP publish for an open `.rbx` reported `call.unresolved-toplevel` for every helper while the
22
22
  # unit sat in the index under its relative name). A path outside the root is left alone.
23
23
  def relative(path, root)
24
- # `expand_path` against the root first, so `./app/views/x.rbx`, `app/views/x.rbx` and
24
+ # `absolute_path` against the root first, so `./app/views/x.rbx`, `app/views/x.rbx` and
25
25
  # `$ROOT/lib/../app/views/x.rbx` all reduce to the one absolute spelling before anything is
26
26
  # compared. Without it an `--instead-of=./app/views/x.rbx` matched no unit and was parsed as plain
27
27
  # Ruby — the spellings a shell and an editor produce are not the spelling `Dir.glob` returns.
28
- root_dir = File.expand_path(root.to_s)
29
- text = File.expand_path(path.to_s, root_dir)
30
- # BOTH spellings of the root. `expand_path` does not resolve symlinks, and the root is not always
28
+ # `absolute_path`, not `expand_path`: a leading `~` is a directory name here (`~drafts/a.rb`), and
29
+ # `expand_path` reads it as a user's home and raises `ArgumentError` for a user that does not exist.
30
+ root_dir = File.absolute_path(root.to_s)
31
+ text = File.absolute_path(path.to_s, root_dir)
32
+ # BOTH spellings of the root. `absolute_path` does not resolve symlinks, and the root is not always
31
33
  # already resolved: `Dir.pwd` is (which is why this was latent), but a caller may pass any path.
32
34
  prefixes(root_dir).each do |prefix|
33
35
  return text.delete_prefix(prefix) if text.start_with?(prefix)
@@ -190,11 +190,11 @@ module Rigor
190
190
  end
191
191
 
192
192
  def [](path)
193
- @entries[normalize(path)]
193
+ entry_for(path)
194
194
  end
195
195
 
196
196
  def key?(path)
197
- @entries.key?(normalize(path))
197
+ !entry_for(path).nil?
198
198
  end
199
199
 
200
200
  # `{ path => ruby_source }` — what {Runner#parse_source} and {WorkerSession#parse_source} read
@@ -287,7 +287,7 @@ module Rigor
287
287
  # it compiles a partial's locals into the method's parameters, and it is what makes `locals:` mean
288
288
  # anything.
289
289
  def parse_scopes(path)
290
- entry = @entries[normalize(path)]
290
+ entry = entry_for(path)
291
291
  return nil if entry.nil? || entry.locals.empty?
292
292
 
293
293
  [entry.locals.keys.map(&:to_sym)]
@@ -301,7 +301,7 @@ module Rigor
301
301
  end
302
302
 
303
303
  def unit_key_for(path)
304
- @entries[normalize(path)]&.unit_key
304
+ entry_for(path)&.unit_key
305
305
  end
306
306
 
307
307
  # Binds the declared `self`, locals and ivar seeds onto the per-file scope, so the unit's body types
@@ -315,7 +315,7 @@ module Rigor
315
315
  # the honest reading of "a receiver is declared and the analyzer cannot see it" (ADR-5), and it is
316
316
  # silent.
317
317
  def seed(scope, path)
318
- entry = @entries[normalize(path)]
318
+ entry = entry_for(path)
319
319
  return scope if entry.nil?
320
320
 
321
321
  scope = bind_self(scope, entry)
@@ -369,6 +369,16 @@ module Rigor
369
369
 
370
370
  private
371
371
 
372
+ # The unit `path` names, or nil. A run with no units answers before reducing the path: the reduction
373
+ # realpaths the root on every call, and every analysed file is looked up at least twice, which made 922
374
+ # `File.realpath` calls on a template-free `rigor check` of the v0.3.9 `lib`
375
+ # ([#1507](https://github.com/rigortype/rigor/issues/1507)).
376
+ def entry_for(path)
377
+ return nil if @entries.empty?
378
+
379
+ @entries[normalize(path)]
380
+ end
381
+
372
382
  def normalize(path)
373
383
  TemplateUnitPaths.relative(path, @root)
374
384
  end
@@ -6,6 +6,7 @@ require_relative "../environment"
6
6
  require_relative "../project_environment"
7
7
  require_relative "../scope"
8
8
  require_relative "../cache/store"
9
+ require_relative "shadow_harness"
9
10
  require_relative "../plugin"
10
11
  require_relative "../rbs_extended/reporter"
11
12
  require_relative "../reflection"
@@ -364,12 +365,12 @@ module Rigor
364
365
  def build_trust_policy
365
366
  trusted_gems = @configuration.plugins.map { |entry| trusted_gem_name(entry) }.uniq
366
367
  roots = [Dir.pwd]
367
- Array(@configuration.signature_paths).each { |sp| roots << File.expand_path(sp) }
368
+ Array(@configuration.signature_paths).each { |sp| roots << File.absolute_path(sp) }
368
369
  trusted_gems.each do |gem_name|
369
370
  path = trusted_gem_root(gem_name)
370
371
  roots << path if path
371
372
  end
372
- @configuration.plugins_io_allowed_paths.each { |p| roots << File.expand_path(p) }
373
+ @configuration.plugins_io_allowed_paths.each { |p| roots << File.absolute_path(p) }
373
374
 
374
375
  Plugin::TrustPolicy.new(
375
376
  trusted_gems: trusted_gems,
@@ -440,7 +441,7 @@ module Rigor
440
441
  results = walk.diagnostics_for_file(
441
442
  path: path, scope: scope, root: root, collector_driver: driver
442
443
  )
443
- if ENV["RIGOR_SHADOW_RULE_WALK"]
444
+ if ShadowHarness.enabled?
444
445
  CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors)
445
446
  end
446
447
  results.each_with_object({}.compare_by_identity) do |result, by_plugin|
@@ -58,7 +58,31 @@ module Rigor
58
58
  # searches ahead of a class's own `def`s. A pre-10 bundle would silently contribute no prepends for an
59
59
  # unchanged file, so that file's consumers would keep answering with the pre-fix MRO — cached bundles
60
60
  # must read as misses once and rebuild carrying the slot.
61
- SCHEMA_VERSION = 10
61
+ # v11: #1558 — a plugin producer served from its own cache now replays its entry's dependency rows into
62
+ # the plugin's IoBoundary, so the run-result entry and the entry of any producer computed after it carry
63
+ # the served value's inputs. A pre-11 entry written while a producer hit lacks those rows and would
64
+ # validate fresh across exactly the edit they exist to catch. A checkout's source digest already moved
65
+ # every such key, and a release clears the root through `Store::PAYLOAD_ABI_VERSION`; the bump closes
66
+ # the remaining window, a same-`VERSION` build installed as `gems/rigortype-<VERSION>`, which
67
+ # {EngineSource} treats as version-pinned (#285) and so keys on the version alone.
68
+ # v12: the def-index seed bundles gain the `:unpositioned_mixins` table, the mixin edges whose order is
69
+ # not a fact, which an ancestor-order reader declines on. A pre-12 bundle would silently contribute no
70
+ # such edge for an unchanged file, so cached bundles must read as misses once and rebuild carrying it.
71
+ # v13/v14: #1587 — the prepend table keeps every statement (a repeated `prepend` is no longer collapsed at
72
+ # the freeze) and a module written to both the include and the prepend table, or a repeated `extend`, is
73
+ # named unpositioned. A pre-14 bundle would hold the collapsed prepend list and miss the taint for an
74
+ # unchanged file, so cached bundles must read as misses once and rebuild.
75
+ # v15: #1592 — an `extend` (or a `class << self` include) written inside a block the walk used to skip is
76
+ # now recorded and named unpositioned. A pre-15 bundle would hold neither for an unchanged file.
77
+ # v16: ADR-119 WD1 — the def-index seed bundles gain the `:siblings` Hash (the `possible_*` / `contested_*`
78
+ # halves of the discovery pairs). A pre-16 bundle would contribute no sibling for an unchanged file, so cached
79
+ # bundles must read as misses once and rebuild carrying it.
80
+ # v17: ADR-119 WD3 — the def-contribution producers fill the siblings (a conditional `def`'s name possible, its
81
+ # def-node slot and envelope contested). A pre-17 bundle carries the empty siblings of the walk before them, so
82
+ # an unchanged file would fold as "no possible fact" where a cold walk records one.
83
+ # v18: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`; a pre-18 bundle
84
+ # carries it empty, so an unchanged file's conditional visibility would fold as certain.
85
+ SCHEMA_VERSION = 18
62
86
 
63
87
  # Per-slot entry value objects. Constructors validate enums / required fields and freeze the resulting
64
88
  # struct so no caller can mutate after the entry is in a Descriptor.
@@ -105,6 +129,21 @@ module Rigor
105
129
  new(path: path, comparator: :stat, value: packed)
106
130
  end
107
131
 
132
+ # A `:stat` row whose recording instant is no later than `limit_ns`: any file whose recorded mtime is at or
133
+ # after `limit_ns` is then racy, and validation re-hashes it instead of trusting its stat tuple. Any other
134
+ # row, and one whose instant is already earlier, is returned as is.
135
+ def with_recording_instant_at_most(limit_ns)
136
+ return self unless comparator == :stat
137
+
138
+ parts = value.split
139
+ return self unless parts.size == 6 && Integer(parts[5], 10) > limit_ns
140
+
141
+ parts[5] = limit_ns.to_s
142
+ self.class.new(path: path, comparator: comparator, value: parts.join(" "))
143
+ rescue ArgumentError
144
+ self
145
+ end
146
+
108
147
  # ADR-45 WD1 (#577) — the absence row: fresh while `path` does not exist, stale the moment anything
109
148
  # (a file, a directory, a symlink) comes into existence there. {Plugin::IoBoundary#read_file}
110
149
  # records one when a read fails because the path is missing, so a value computed on "X is absent"
@@ -112,6 +112,34 @@ module Rigor
112
112
  hexdigest(path) == digest
113
113
  end
114
114
 
115
+ # ADR-45 WD2 (#1507) — `packed` re-packed against `path`'s current stat, for a writer about to record it
116
+ # again: when the tuple moved (a `touch`, a checkout) or the entry is racy, but the file still hashes to the
117
+ # recorded digest, the new pack carries the current tuple and this run's recording instant, so the next
118
+ # validation is one `File::Stat` again rather than a re-hash every time.
119
+ #
120
+ # The hash is taken fresh, never from the per-run memo: a memoised digest describes the bytes as they were
121
+ # when change detection read them, and pairing it with a later stat would vouch for bytes written since. And
122
+ # the re-pack is kept only when the tuple it packed, taken after the hash, is the one taken before it: a
123
+ # write landing while the file is hashed moves the change time, and leaves `packed` as it was.
124
+ #
125
+ # `packed` comes back unchanged when its tuple still matches and it is not racy, and whenever the bytes no
126
+ # longer match the digest: a row that must stay stale is never refreshed into a fresh one. nil when `packed`
127
+ # is not a stat pack. A stat failure (the file is gone) returns `packed` unchanged, which then validates as
128
+ # stale.
129
+ def self.refresh_stat(path, packed)
130
+ parsed = parse_stat(packed)
131
+ return nil if parsed.nil?
132
+
133
+ before = File.stat(path)
134
+ return packed if !racy?(parsed) && tuple_matches?(before, parsed)
135
+ return packed unless Digest::SHA256.file(path).hexdigest == parsed[0]
136
+
137
+ repacked = pack_stat(path, parsed[0])
138
+ repacked && tuple_matches?(before, parse_stat(repacked)) ? repacked : packed
139
+ rescue SystemCallError
140
+ packed
141
+ end
142
+
115
143
  # The content-digest field of a packed `:stat` entry ({.pack_stat}); nil when the entry is absent or not
116
144
  # well-formed. Exposed for the caller that must compare the recorded digest against bytes it hashes
117
145
  # itself rather than against the file on disk — the incremental session's editor-mode freshness check,
@@ -5,6 +5,10 @@ require "digest"
5
5
  require "zlib"
6
6
 
7
7
  require_relative "engine_source"
8
+ require_relative "store"
9
+ require_relative "../analysis/shadow_harness"
10
+ require_relative "../analysis/run_cache_key"
11
+ require_relative "../environment"
8
12
 
9
13
  module Rigor
10
14
  module Cache
@@ -138,7 +142,33 @@ module Rigor
138
142
  # the `write` / `to_str` / `to_int` / hatch definitions and top-level mixins it holds, which the `global.*`
139
143
  # write rules decline on project-wide. A pre-29 bundle would fold as "none of those", and a warm run would
140
144
  # report a write a cold run exempts.
141
- SCHEMA = 29
145
+ # 30: issue #1536 gives each seed bundle a `source_rbs_digest` — the digest of what every loaded source-RBS
146
+ # synthesizer contributes for the file — which ADR-89 WD1 now compares beside `declaration_signature`
147
+ # in place of switching the gate off whenever `rigor-rbs-inline` is configured. A pre-30 bundle
148
+ # carries none, which the session reads as "unknown" and so as moved: sound, but every first edit to
149
+ # each file would re-analyse the whole project, so the gate rejects the blob and the next run is cold.
150
+ # 31: issue #1574 digests `plugin_fact_digest` and a seed bundle's `source_rbs_digest` with
151
+ # {ValueDigest} instead of over `Marshal.dump` bytes, so the same value digests alike whether a producer
152
+ # computed it or its cache entry served it. A pre-31 digest is in the old form and would never match, so
153
+ # the next run would go cold anyway; the bump makes that a clean miss instead of a "plugin fact surface
154
+ # changed" note that nothing changed.
155
+ # 32: each seed bundle gains an `unpositioned_mixins` table — the file's mixin edges whose order is not a
156
+ # fact (`Scope::DiscoveryIndex#unpositioned_mixins`), which an ancestor-order reader declines on. A pre-32
157
+ # bundle would fold as "every edge positioned", and a warm run would answer an order a cold run declines.
158
+ # 33: the mixin producers change what a seed bundle holds (#1587 — the prepend table keeps every statement,
159
+ # and a both-kind module or a repeated `extend` is named unpositioned), so a pre-33 bundle folds as the
160
+ # collapsed, positioned table and a warm run would answer an order a cold run declines.
161
+ # 34: #1592 — an `extend` or a `class << self` include inside a block the extends walk used to skip is now
162
+ # recorded and named unpositioned, so a pre-34 bundle folds as if the edge did not exist.
163
+ # 35: ADR-119 WD1 — each seed bundle gains a `siblings` Hash, the `possible_*` / `contested_*` siblings of the
164
+ # `Scope::DiscoveryIndex` members that may admit a possible fact. Every sibling is empty until a producer
165
+ # fills one, so a pre-35 bundle folds as "no possible fact", which is what it would hold today; the bump keeps
166
+ # that true once one does.
167
+ # 36: ADR-119 WD3 — the def-contribution producers fill those siblings, so a pre-36 bundle folds an unchanged
168
+ # file's conditional `def` as certain and a warm run would answer a definer a cold run declines.
169
+ # 37: ADR-119 WD3 — the visibility walk fills `contested_discovered_method_visibilities`, which a pre-37 bundle
170
+ # carries empty.
171
+ SCHEMA = 37
142
172
 
143
173
  # The persisted per-file state.
144
174
  # `cache` maps an analyzed file to its diagnostics.
@@ -210,8 +240,13 @@ module Rigor
210
240
  # rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
211
241
  # resolved configuration, the analysis **roots** (the path arguments, e.g. `["lib"]`, NOT the expanded
212
242
  # file list — so a snapshot is keyed to an invocation's roots but adding / removing a file under them is
213
- # handled incrementally by the session, not a full rebuild), the resolved gem set (`Gemfile.lock` /
214
- # `rbs_collection`), and the project's own RBS (`signature_paths` file contents). Built WITHOUT
243
+ # handled incrementally by the session, not a full rebuild), the resolved gem set (the lockfiles
244
+ # {Analysis::RunCacheKey.resolved_lockfile_paths} resolves — `bundler.lockfile:` / `rbs_collection.lockfile:`,
245
+ # else `./Gemfile.lock` / `./rbs_collection.lock.yaml` — the same files the run-result key digests), the
246
+ # project's own RBS (the `sig/` roots the environment actually loads: the explicit
247
+ # `signature_paths`, or the auto-detected `<root>/sig` when `signature_paths` is nil), and the contents of
248
+ # every `pre_eval:` file (its PATH is in the configuration, but an edit to a file outside the analysed
249
+ # paths changes no analysed file, so it needs its own part). Built WITHOUT
215
250
  # constructing the RBS environment so the warm path can gate the load cheaply, before the costly env
216
251
  # build. The `--verify-incremental` gate is the safety net for any under-capture (it would surface as an
217
252
  # incremental-vs-full mismatch). Returns nil on any error → the caller falls back to a non-persisted run.
@@ -225,16 +260,22 @@ module Rigor
225
260
  # part, so a released gem's fingerprint is byte-identical to the pre-#289 one and its warm snapshots
226
261
  # survive the upgrade untouched.
227
262
  def self.fingerprint(configuration:, roots:)
263
+ bundler_lock, collection_lock = Analysis::RunCacheKey.resolved_lockfile_paths(configuration)
228
264
  parts = [
229
265
  "engine:#{Rigor::VERSION}:#{SCHEMA}",
230
266
  "config:#{Digest::SHA256.hexdigest(Marshal.dump(configuration.to_h))}",
231
267
  "roots:#{Array(roots).map(&:to_s).sort.join("\n")}",
232
- "gems:#{digest_file_if_present('Gemfile.lock')}",
233
- "rbs_collection:#{digest_file_if_present('rbs_collection.lock.yaml')}",
234
- "sig:#{digest_signature_paths(configuration.signature_paths)}"
268
+ "gems:#{digest_file_if_present(bundler_lock)}",
269
+ "rbs_collection:#{digest_file_if_present(collection_lock)}",
270
+ "sig:#{digest_signature_paths(project_sig_roots(configuration))}",
271
+ "pre_eval:#{digest_pre_eval(configuration.pre_eval)}"
235
272
  ]
236
273
  identity = EngineSource.process_identity
237
274
  parts << "engine-source:#{identity}" if identity
275
+ # ADR-116 WD5 — the snapshot's per-file rows and seed bundles are only as checked as the run that
276
+ # wrote them ({Inference::DeclarationWalk::Shadow}); nil with the harness off adds no part.
277
+ shadow = Analysis::ShadowHarness.cache_identity
278
+ parts << "shadow-harness:#{shadow}" if shadow
238
279
  Digest::SHA256.hexdigest(parts.join("\x00"))
239
280
  rescue StandardError
240
281
  # {EngineSource::Unavailable} lands here too, and nil is the answer it requires rather than one it
@@ -246,10 +287,29 @@ module Rigor
246
287
  end
247
288
 
248
289
  def self.digest_file_if_present(path)
249
- File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
290
+ path = path&.to_s
291
+ path && File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
250
292
  end
251
293
  private_class_method :digest_file_if_present
252
294
 
295
+ # Content-digest every `pre_eval:` file in configuration order, so an edit to one invalidates the snapshot
296
+ # even when it lies outside the analysed paths. A missing file digests as "absent", so creating it counts too.
297
+ def self.digest_pre_eval(paths)
298
+ digest = Digest::SHA256.new
299
+ Array(paths).each do |path|
300
+ digest << path.to_s << "\0" << digest_file_if_present(path.to_s) << "\0"
301
+ end
302
+ digest.hexdigest
303
+ end
304
+ private_class_method :digest_pre_eval
305
+
306
+ # The `sig/` roots the environment loads for this configuration: the configured `signature_paths`, or the
307
+ # auto-detected `<root>/sig` when those are nil ({Environment.project_signature_roots}).
308
+ def self.project_sig_roots(configuration)
309
+ Environment.project_signature_roots(configuration.signature_paths).map(&:to_s)
310
+ end
311
+ private_class_method :project_sig_roots
312
+
253
313
  # Content-digest every `.rbs` under the configured signature paths (sorted for determinism) so a project
254
314
  # RBS edit invalidates the snapshot. Sig trees are small; content (not mtime) keeps it stable across
255
315
  # checkouts.
@@ -351,7 +411,9 @@ module Rigor
351
411
  effects_identity: payload.effects_identity,
352
412
  run_level_rows: payload.run_level_rows
353
413
  )
354
- blob = Zlib::Deflate.deflate(raw)
414
+ # #1507 — every `--incremental` run that analysed anything rewrites this whole blob, so its deflate is
415
+ # on the edit path; see {Store::DEFLATE_LEVEL}.
416
+ blob = Zlib::Deflate.deflate(raw, Store::DEFLATE_LEVEL)
355
417
  tmp = "#{@path}.#{Process.pid}.tmp"
356
418
  File.binwrite(tmp, blob)
357
419
  File.rename(tmp, @path)
@@ -25,7 +25,8 @@ module Rigor
25
25
  # On-disk byte-layout version. Bumped on incompatible format changes (independent of
26
26
  # {Descriptor::SCHEMA_VERSION}, which covers the descriptor schema rather than the byte layout). v2
27
27
  # (ADR-54 WD2): the value payload is zlib-deflated on write and inflated on read — Marshal blobs
28
- # compress to 13–16 % at an inflate cost an order of magnitude below their `Marshal.load`. v1 entries
28
+ # compressed to 13–16 % at zlib's default level (now {DEFLATE_LEVEL}) at an inflate cost an order of
29
+ # magnitude below their `Marshal.load`. v1 entries
29
30
  # fail the header check and read as silent misses; the `schema_version.txt` marker additionally carries
30
31
  # this version, so the first writable run after a bump clears the root and reclaims the unreadable
31
32
  # bytes.
@@ -60,6 +61,14 @@ module Rigor
60
61
 
61
62
  STALE_TEMP_FILE_AGE_SECONDS = 60 * 60
62
63
 
64
+ # #1507 — the zlib level for everything written under the cache root: these value payloads (ADR-54 WD2),
65
+ # the incremental snapshot, and `rigor unused`'s scan bundle. The snapshot and the scan bundle are
66
+ # rewritten whole whenever a run changed anything, so their deflate is on the edit path. On Mastodon the
67
+ # default level took 122 ms for the snapshot (9.5 MB raw) and 131 ms for `rbs.environment` (22.8 MB raw);
68
+ # `BEST_SPEED` takes 38 ms and 49 ms, for a cache about a quarter larger. Inflate reads any level, so the
69
+ # level is not part of the format.
70
+ DEFLATE_LEVEL = Zlib::BEST_SPEED
71
+
63
72
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte format version.
64
73
  HEADER = "RIGOR\x00#{FORMAT_VERSION.chr}".b.freeze
65
74
 
@@ -241,8 +250,13 @@ module Rigor
241
250
  # validation always re-checks the filesystem — but a single run only looks up once.
242
251
  #
243
252
  # `generation_cap:` carries the same producer-declared compaction budget as {#fetch_or_compute}.
253
+ #
254
+ # `on_hit:` is called with the entry's stored dependency descriptor on a fresh hit, before the value
255
+ # is returned, and never on a miss. A caller whose own dependency record must carry a served value's
256
+ # inputs replays them from it (#1558, {Plugin::Base#cache_for}): a hit runs no block, so nothing else
257
+ # reports what the value was computed from.
244
258
  def fetch_or_validate(producer_id:, key_descriptor:, generation_cap:, params: {},
245
- serialize: nil, deserialize: nil)
259
+ serialize: nil, deserialize: nil, on_hit: nil)
246
260
  validate_producer_id!(producer_id)
247
261
  declare_generation_cap(producer_id, generation_cap)
248
262
  disk = ensure_schema_version!
@@ -252,6 +266,7 @@ module Rigor
252
266
  cached = path && read_entry(path, deserialize: deserialize)
253
267
  if (validated = fresh_pair_value(cached))
254
268
  @monitor.synchronize { record(:hits, producer_id) }
269
+ on_hit&.call(validated[1])
255
270
  return validated[0]
256
271
  end
257
272
 
@@ -285,6 +300,51 @@ module Rigor
285
300
  validated[0]
286
301
  end
287
302
 
303
+ # ADR-45 WD2 (#1507) — a record-and-validate entry's value WITHOUT validating its dependencies. The
304
+ # `--incremental` session reads the previous run-result slot to carry forward the rows a narrowed run
305
+ # cannot re-derive, and that slot is expected to be stale: the edit that started this run is in it. So
306
+ # the answer here is never served — only rows are taken from it, and they are validated again, with
307
+ # everything else, the next time the slot is read. nil on a miss, a malformed entry or an unavailable
308
+ # disk tier. Records neither a hit nor a miss, since nothing was looked up to be served.
309
+ def peek_unvalidated(producer_id:, key_descriptor:, params: {})
310
+ validate_producer_id!(producer_id)
311
+ return nil unless ensure_schema_version!
312
+
313
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
314
+ pair = read_entry(entry_path(producer_id, key))&.value
315
+ return nil unless pair.is_a?(Array) && pair.size == 2 && pair[1].is_a?(Descriptor)
316
+
317
+ pair[0]
318
+ end
319
+
320
+ # ADR-45 WD2 (#1507) — the write half of {#fetch_or_validate} on its own, and unconditional. That method
321
+ # writes only after it failed to validate what is there; a writer that has just computed an answer of its
322
+ # own must store THAT answer even when the entry already on disk still validates, because it is the one
323
+ # the run printed. Same entry format and the same failure contract as {#fetch_or_validate}'s write: a
324
+ # disk-side failure is swallowed, a value `Marshal` cannot dump raises. Returns whether it wrote.
325
+ def store_validated(producer_id:, key_descriptor:, generation_cap:, value:, dependencies:, params: {})
326
+ validate_producer_id!(producer_id)
327
+ declare_generation_cap(producer_id, generation_cap)
328
+ return false unless ensure_schema_version!
329
+
330
+ key = key_descriptor.cache_key_for(producer_id: producer_id, params: params)
331
+ wrote = try_write_entry(entry_path(producer_id, key), key_descriptor, [value, dependencies])
332
+ @monitor.synchronize { record(:writes, producer_id) } if wrote
333
+ wrote
334
+ end
335
+
336
+ # ADR-45 WD2 (#1507) — removes one entry, for a writer that has just superseded it under another key and
337
+ # knows nothing will ask for it again. Best-effort like every other unlink here; a no-op on a read-only
338
+ # store.
339
+ def discard(producer_id:, key_descriptor:, params: {})
340
+ validate_producer_id!(producer_id)
341
+ return if @read_only || !ensure_schema_version!
342
+
343
+ path = entry_path(producer_id, key_descriptor.cache_key_for(producer_id: producer_id, params: params))
344
+ unlink_entry_and_shard?(path) if File.file?(path)
345
+ nil
346
+ end
347
+
288
348
  # ADR-6 § "Eviction" — compaction pass over the on-disk cache. No-op when the store is read-only.
289
349
  #
290
350
  # The generation cap of pass 2 comes from what the producers themselves declared through this Store's
@@ -469,7 +529,7 @@ module Rigor
469
529
  FileUtils.mkdir_p(File.dirname(path))
470
530
 
471
531
  descriptor_bytes = descriptor.to_canonical_bytes
472
- value_bytes = Zlib::Deflate.deflate(serialize_value(value, serialize))
532
+ value_bytes = Zlib::Deflate.deflate(serialize_value(value, serialize), DEFLATE_LEVEL)
473
533
 
474
534
  body = +"".b
475
535
  body << HEADER