rigortype 0.1.17 → 0.1.19

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -39,12 +39,19 @@ module Rigor
39
39
  # - `plugin_blueprints` — Phase 3a
40
40
  # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
41
41
  # - `explain` — Boolean.
42
- # - `synthetic_method_index` / `project_patched_methods`
43
- # optional (default `nil`). NOT `Ractor.shareable?`, so the
44
- # Ractor pool path leaves them unset; the fork backend
42
+ # - `synthetic_method_index` / `project_patched_methods` /
43
+ # `project_scope_seed` — optional (default `nil` / `{}`). NOT
44
+ # `Ractor.shareable?` (the seed tables carry Prism def nodes),
45
+ # so the Ractor pool path leaves them unset; the fork backend
45
46
  # (ADR-15 Amendment), which builds the session pre-fork on the
46
47
  # parent, threads the runner's project-scan results through so
47
48
  # per-file inference matches the sequential path exactly.
49
+ # `project_scope_seed` is `Runner#project_scope_seed_tables` —
50
+ # the cross-file discovery tables `seed_project_scope` applies
51
+ # to every per-file scope on the sequential path; without it a
52
+ # worker cannot resolve calls to methods defined in OTHER
53
+ # project files and emits `call.undefined-method` false
54
+ # positives the sequential path does not.
48
55
  #
49
56
  # Internally the session OWNS (and never shares):
50
57
  #
@@ -97,13 +104,14 @@ module Rigor
97
104
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
98
105
  plugin_blueprints: [], explain: false, buffer: nil,
99
106
  synthetic_method_index: nil, project_patched_methods: nil,
100
- source_files: [])
107
+ project_scope_seed: {}, source_files: [])
101
108
  @configuration = configuration
102
109
  @cache_store = cache_store
103
110
  @explain = explain
104
111
  @buffer = buffer
105
112
  @synthetic_method_index = synthetic_method_index
106
113
  @project_patched_methods = project_patched_methods
114
+ @project_scope_seed = project_scope_seed || {}
107
115
  # ADR-32 WD4 — full project file list (frozen
108
116
  # Array<String>) for env-build-time invocation of any
109
117
  # loaded plugin's `source_rbs_synthesizer` callable.
@@ -165,16 +173,20 @@ module Rigor
165
173
  return parse_diagnostics(path, parse_result)
166
174
  end
167
175
 
168
- scope = Scope.empty(environment: @environment, source_path: path)
176
+ scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
169
177
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
178
+ # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
179
+ node_collectors = CheckRules.build_node_collectors(path, index)
180
+ node_results = node_rule_results_by_plugin(path, parse_result.value, scope, node_collectors, index)
170
181
  diagnostics = CheckRules.diagnose(
171
182
  path: path,
172
183
  root: parse_result.value,
173
184
  scope_index: index,
174
185
  comments: parse_result.comments,
175
- disabled_rules: @configuration.disabled_rules
186
+ disabled_rules: @configuration.disabled_rules,
187
+ node_collectors: node_collectors
176
188
  )
177
- diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
189
+ diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope, node_results)
178
190
  diagnostics + explain_diagnostics(path, parse_result.value, scope)
179
191
  rescue Errno::ENOENT => e
180
192
  [analyzer_error(path, e.message)]
@@ -200,6 +212,17 @@ module Rigor
200
212
 
201
213
  private
202
214
 
215
+ # Mirrors {Runner#seed_project_scope}: applies the cross-file
216
+ # pre-pass discovery tables the constructor received (fork
217
+ # backend only — see the class comment) to a fresh per-file
218
+ # scope, so worker-side inference resolves project-internal
219
+ # cross-file calls exactly like the sequential path.
220
+ def seed_project_scope(scope)
221
+ return scope if @project_scope_seed.empty?
222
+
223
+ scope.with_discovery(scope.discovery.with(**@project_scope_seed))
224
+ end
225
+
203
226
  # See {Runner#parse_source}. Same contract: if `@buffer`
204
227
  # binds `path` to a physical file, read the physical bytes
205
228
  # but stamp the parse buffer's `filepath:` as the LOGICAL
@@ -275,17 +298,43 @@ module Rigor
275
298
  )
276
299
  end
277
300
 
278
- def plugin_emitted_diagnostics(path, root, scope)
301
+ def plugin_emitted_diagnostics(path, root, scope, node_results)
279
302
  return [] if @plugin_registry.empty?
280
303
 
281
304
  @plugin_registry.plugins.flat_map do |plugin|
282
- collect_plugin_diagnostics(plugin, path, root, scope)
305
+ collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
283
306
  end
284
307
  end
285
308
 
286
- def collect_plugin_diagnostics(plugin, path, root, scope)
309
+ # ADR-52 WD4 + ADR-53 B4 — single engine-owned walk per file drives
310
+ # both the plugin node rules (bucketed per plugin in registry order,
311
+ # plugin-major emission) and the built-in node collectors
312
+ # (`node_collectors`, populated in place). Runs even with no node-rule
313
+ # plugins so the collectors still get driven (converged path).
314
+ def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
315
+ walk = @plugin_registry.node_rule_walk
316
+ driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
317
+ return {}.compare_by_identity if walk.empty? && driver.nil?
318
+
319
+ results = walk.diagnostics_for_file(
320
+ path: path, scope: scope, root: root, collector_driver: driver
321
+ )
322
+ if ENV["RIGOR_SHADOW_RULE_WALK"]
323
+ CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors)
324
+ end
325
+ results.each_with_object({}.compare_by_identity) do |result, by_plugin|
326
+ by_plugin[result.plugin] = result
327
+ end
328
+ end
329
+
330
+ def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
287
331
  raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
288
- raw += plugin.node_rule_diagnostics(path: path, scope: scope, root: root)
332
+ # A node-rule context/rule raise isolates the whole plugin's
333
+ # node-rule contribution, matching the old combined per-plugin
334
+ # rescue (which discarded `diagnostics_for_file` output too).
335
+ raise node_result.error if node_result&.error
336
+
337
+ raw += node_result.diagnostics if node_result
289
338
  raw.map { |diagnostic| stamp_plugin_diagnostic(diagnostic, plugin.manifest.id) }
290
339
  rescue StandardError => e
291
340
  [plugin_runtime_error_diagnostic(path, plugin, e)]
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # ADR-50 § WD2 — the bleeding-edge overlay.
5
+ #
6
+ # A Rigor-maintained set of the *next major's* queued changes —
7
+ # severity-map promotions and new-discipline rule enablements — that a
8
+ # user can adopt early, before they become default-on at a major
9
+ # (ADR-50 § WD7). It is orthogonal to `severity_profile:` (how loud
10
+ # *today's* rules are) and is versioned with the gem, NOT a
11
+ # user-supplied file: the inspectable counterpart to PHPStan's
12
+ # `bleedingEdge` include.
13
+ #
14
+ # The overlay is **empty today** — no discipline has yet been queued
15
+ # for the next major. This module is the WD2 *foundation* (the v0.1.19
16
+ # slice): the surface (`bleeding_edge:` config, the
17
+ # `rigor show-bleedingedge` command, the severity-composition hook in
18
+ # {Configuration::SeverityProfile.resolve}) exists and is wired
19
+ # end-to-end, so the first real feature lands as a single {FEATURES}
20
+ # entry with no engine plumbing.
21
+ #
22
+ # Each feature carries a **stable feature id** — part of the ADR-50
23
+ # WD1 contract vocabulary: the config, the `show` command, and the
24
+ # eventual CHANGELOG migration note all name the same id, and a
25
+ # feature graduates to default-on at a major by being removed from
26
+ # {FEATURES}.
27
+ module BleedingEdge
28
+ # One queued change.
29
+ #
30
+ # @!attribute id
31
+ # @return [String] the stable feature id (contract vocabulary).
32
+ # @!attribute summary
33
+ # @return [String] a one-line description of what it changes.
34
+ # @!attribute severity_overrides
35
+ # @return [Hash{String => Symbol}] canonical rule id → the
36
+ # severity this feature imposes. Composed *below* the user's own
37
+ # `severity_overrides:` and *above* the active `severity_profile`
38
+ # (see {Configuration::SeverityProfile.resolve}).
39
+ Feature = Data.define(:id, :summary, :severity_overrides) do
40
+ def to_h
41
+ {
42
+ "id" => id,
43
+ "summary" => summary,
44
+ "severity_overrides" => severity_overrides.transform_values(&:to_s)
45
+ }
46
+ end
47
+ end
48
+
49
+ # The overlay. Empty until the first next-major discipline is
50
+ # queued; add a {Feature} here (with a stable id) when one is.
51
+ FEATURES = [].freeze
52
+
53
+ module_function
54
+
55
+ # @return [Array<Feature>] the whole overlay.
56
+ def features
57
+ FEATURES
58
+ end
59
+
60
+ # @return [Array<String>] every feature id in the overlay.
61
+ def feature_ids
62
+ FEATURES.map(&:id)
63
+ end
64
+
65
+ # @param id [String]
66
+ # @return [Feature, nil]
67
+ def feature(id)
68
+ FEATURES.find { |f| f.id == id }
69
+ end
70
+
71
+ # Resolves a normalized `bleeding_edge:` selector (see
72
+ # {Configuration#bleeding_edge}) to the active {Feature} list.
73
+ # Unknown ids in a `list` / `except` selector are simply absent from
74
+ # the overlay and contribute nothing — symmetric with how
75
+ # `severity_overrides:` keeps an unknown rule id inert until it
76
+ # lands (robust across gem versions).
77
+ #
78
+ # @param selector [Hash] `{ "mode" => "none" }`,
79
+ # `{ "mode" => "all" }`, `{ "mode" => "all", "except" => [ids] }`,
80
+ # or `{ "mode" => "list", "ids" => [ids] }`.
81
+ # @return [Array<Feature>]
82
+ def active_features(selector)
83
+ case selector["mode"]
84
+ when "all"
85
+ except = selector["except"] || []
86
+ FEATURES.reject { |f| except.include?(f.id) }
87
+ when "list"
88
+ ids = selector["ids"] || []
89
+ FEATURES.select { |f| ids.include?(f.id) }
90
+ else
91
+ []
92
+ end
93
+ end
94
+
95
+ # The merged severity-override map the active features impose for a
96
+ # selector. Frozen so the result is `Ractor.shareable?`.
97
+ #
98
+ # @param selector [Hash] see {#active_features}.
99
+ # @return [Hash{String => Symbol}]
100
+ def severity_overrides_for(selector)
101
+ active_features(selector).each_with_object({}) do |feature, acc|
102
+ acc.merge!(feature.severity_overrides)
103
+ end.freeze
104
+ end
105
+
106
+ # Feature ids named by a selector that are NOT in the overlay
107
+ # (typo / graduated / from a newer gem). Surfaced by
108
+ # `rigor show-bleedingedge` as a hint; never an error.
109
+ #
110
+ # @param selector [Hash] see {#active_features}.
111
+ # @return [Array<String>]
112
+ def unknown_selected_ids(selector)
113
+ named =
114
+ case selector["mode"]
115
+ when "list" then selector["ids"] || []
116
+ when "all" then selector["except"] || []
117
+ else []
118
+ end
119
+ known = feature_ids
120
+ named.reject { |id| known.include?(id) }
121
+ end
122
+ end
123
+ end
@@ -13,8 +13,8 @@ module Rigor
13
13
  # ([`Rigor::Cache::Store`](store.rb), v0.0.8 slice 2) consumes
14
14
  # descriptors but does not extend them.
15
15
  #
16
- # The descriptor has four slots (`files`, `gems`, `plugins`,
17
- # `configs`); every slot is an array of typed entries; an empty
16
+ # The descriptor has six slots (`files`, `gems`, `plugins`,
17
+ # `configs`, `dependencies`, `globs`); every slot is an array of typed entries; an empty
18
18
  # array means "no dependency in this slot". Composition unions
19
19
  # by key per slot; conflicts on the comparison fields raise
20
20
  # {Conflict}.
@@ -32,7 +32,12 @@ module Rigor
32
32
  # references but never declares, so the marshalled RBS env
33
33
  # cached by an older Rigor (which would leave those signatures
34
34
  # inert) MUST be rebuilt for the synthesis to take effect.
35
- SCHEMA_VERSION = 3
35
+ # v4: ADR-60 WD3 added the `globs` slot ({GlobEntry}) for the
36
+ # record-and-validate plugin-producer cache; the new slot
37
+ # changes `#to_canonical_hash` (and is Marshal-dumped inside
38
+ # `fetch_or_validate` entry pairs), so entries written by an
39
+ # older Rigor must read as misses.
40
+ SCHEMA_VERSION = 4
36
41
 
37
42
  # Per-slot entry value objects. Constructors validate enums /
38
43
  # required fields and freeze the resulting struct so no caller
@@ -160,6 +165,62 @@ module Rigor
160
165
  end
161
166
  end
162
167
 
168
+ # ADR-60 WD3 — one glob's-worth of watched files, digested as a
169
+ # single value so the entry covers content change, addition,
170
+ # AND removal in one row: the digest is the SHA-256 over the
171
+ # sorted `"<path>\0<sha256-of-content>\n"` rows of every file
172
+ # matching `File.join(root, pattern)`. A new file adds a row, a
173
+ # deleted file drops one, an edit changes one — all three move
174
+ # the digest. {Descriptor#fresh?} re-runs the same computation
175
+ # and compares.
176
+ class GlobEntry
177
+ include Rigor::ValueSemantics
178
+
179
+ attr_reader :root, :pattern, :value
180
+
181
+ value_fields :root, :pattern, :value
182
+
183
+ def initialize(root:, pattern:, value:)
184
+ @root = root.to_s.dup.freeze
185
+ @pattern = pattern.to_s.dup.freeze
186
+ @value = value.to_s.dup.freeze
187
+ freeze
188
+ end
189
+
190
+ # Builds the entry for the glob's CURRENT filesystem state.
191
+ def self.compute(root:, pattern:)
192
+ new(root: root, pattern: pattern, value: digest_for(root: root, pattern: pattern))
193
+ end
194
+
195
+ # The digest the entry's `value` carries. Per-file read
196
+ # failures (a file vanishing between the glob and the
197
+ # digest) are treated as the file being absent — same
198
+ # race posture as {Descriptor#file_entry_fresh?}.
199
+ def self.digest_for(root:, pattern:)
200
+ # Dir.glob returns sorted entries by default (sort: true),
201
+ # so the row order — and therefore the digest — is stable.
202
+ rows = Dir.glob(File.join(root, pattern)).filter_map do |path|
203
+ next nil unless File.file?(path)
204
+
205
+ "#{path}\0#{Digest::SHA256.file(path).hexdigest}\n"
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ Digest::SHA256.hexdigest(rows.join)
210
+ end
211
+
212
+ # Composition key — {.compose} unions per (root, pattern)
213
+ # slot; two contributions for the same slot must agree on
214
+ # the digest or {Conflict} is raised.
215
+ def slot_key
216
+ "#{root}\0#{pattern}"
217
+ end
218
+
219
+ def to_h
220
+ { "root" => root, "pattern" => pattern, "value" => value }
221
+ end
222
+ end
223
+
163
224
  # Raised when {.compose} encounters incompatible entries
164
225
  # under the same key (file digest mismatch, gem-locked
165
226
  # disagreement, …). Callers handle the exception by
@@ -167,14 +228,15 @@ module Rigor
167
228
  # contribution silently.
168
229
  class Conflict < StandardError; end
169
230
 
170
- attr_reader :files, :gems, :plugins, :configs, :dependencies
231
+ attr_reader :files, :gems, :plugins, :configs, :dependencies, :globs
171
232
 
172
- def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [])
233
+ def initialize(files: [], gems: [], plugins: [], configs: [], dependencies: [], globs: [])
173
234
  @files = files.dup.freeze
174
235
  @gems = gems.dup.freeze
175
236
  @plugins = plugins.dup.freeze
176
237
  @configs = configs.dup.freeze
177
238
  @dependencies = dependencies.dup.freeze
239
+ @globs = globs.dup.freeze
178
240
  freeze
179
241
  end
180
242
 
@@ -185,11 +247,15 @@ module Rigor
185
247
  # `files` are checked — non-file inputs (config / gems / version)
186
248
  # belong in the cache *key*, not the validated dependency set — so
187
249
  # a descriptor carrying any non-file slot is never considered fresh
188
- # (it was built wrong for this use).
250
+ # (it was built wrong for this use). ADR-60 WD3 adds `globs`
251
+ # alongside `files` as a re-validatable slot: a {GlobEntry} is
252
+ # fresh when re-globbing + re-digesting reproduces its recorded
253
+ # value.
189
254
  def fresh?
190
255
  return false unless gems.empty? && plugins.empty? && configs.empty? && dependencies.empty?
191
256
 
192
- files.all? { |entry| file_entry_fresh?(entry) }
257
+ files.all? { |entry| file_entry_fresh?(entry) } &&
258
+ globs.all? { |entry| glob_entry_fresh?(entry) }
193
259
  end
194
260
 
195
261
  # File-comparator strictness ordering. `:digest` is strictest
@@ -212,7 +278,9 @@ module Rigor
212
278
  plugins = compose_by_key(descriptors.flat_map(&:plugins), :id)
213
279
  configs = compose_by_key(descriptors.flat_map(&:configs), :key)
214
280
  dependencies = compose_by_key(descriptors.flat_map(&:dependencies), :gem_name)
215
- new(files: files, gems: gems, plugins: plugins, configs: configs, dependencies: dependencies)
281
+ globs = compose_by_key(descriptors.flat_map(&:globs), :slot_key)
282
+ new(files: files, gems: gems, plugins: plugins, configs: configs,
283
+ dependencies: dependencies, globs: globs)
216
284
  end
217
285
 
218
286
  # @param producer_id [String]
@@ -241,6 +309,7 @@ module Rigor
241
309
  "dependencies" => sort_entries(dependencies, "gem_name").map(&:to_h),
242
310
  "files" => sort_entries(files, "path").map(&:to_h),
243
311
  "gems" => sort_entries(gems, "name").map(&:to_h),
312
+ "globs" => globs.sort_by { |e| [e.root, e.pattern] }.map(&:to_h),
244
313
  "plugins" => sort_entries(plugins, "id").map(&:to_h)
245
314
  }
246
315
  end
@@ -291,6 +360,15 @@ module Rigor
291
360
  false
292
361
  end
293
362
 
363
+ # ADR-60 WD3 — re-runs the entry's glob + digest and compares
364
+ # against the recorded value. Any failure reads as stale
365
+ # (recompute), never a crash.
366
+ def glob_entry_fresh?(entry)
367
+ GlobEntry.digest_for(root: entry.root, pattern: entry.pattern) == entry.value
368
+ rescue StandardError
369
+ false
370
+ end
371
+
294
372
  def sort_entries(entries, key)
295
373
  entries.sort_by { |e| e.to_h.fetch(key).to_s }
296
374
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "digest"
5
+ require "zlib"
5
6
 
6
7
  module Rigor
7
8
  module Cache
@@ -28,8 +29,12 @@ module Rigor
28
29
  # is cold). A cache must never break a run (the ADR-45 invariant).
29
30
  class IncrementalSnapshot
30
31
  # Bump when the on-disk shape changes so stale snapshots are ignored
31
- # rather than mis-deserialized.
32
- SCHEMA = 4
32
+ # rather than mis-deserialized. 5: the blob is zlib-deflated
33
+ # (ADR-54 WD2 parity with `Store` entries — the snapshot is the
34
+ # one cache artefact that does not go through `Store`); a raw
35
+ # pre-5 blob fails the inflate and loads as nil, the usual
36
+ # fault-tolerant cold-run path.
37
+ SCHEMA = 5
33
38
 
34
39
  # The persisted per-file state.
35
40
  # `cache` maps an analyzed file to its diagnostics.
@@ -103,7 +108,7 @@ module Rigor
103
108
  # The stored {Payload}, or nil when absent / unreadable / schema or
104
109
  # fingerprint mismatch / corrupt. Never raises.
105
110
  def load(fingerprint:)
106
- data = Marshal.load(File.binread(@path)) # rubocop:disable Security/MarshalLoad
111
+ data = Marshal.load(Zlib::Inflate.inflate(File.binread(@path))) # rubocop:disable Security/MarshalLoad
107
112
  return nil unless data.is_a?(Hash) && data[:schema] == SCHEMA && data[:fingerprint] == fingerprint
108
113
 
109
114
  Payload.new(
@@ -125,7 +130,7 @@ module Rigor
125
130
  # raises).
126
131
  def save(fingerprint:, payload:)
127
132
  FileUtils.mkdir_p(File.dirname(@path))
128
- blob = Marshal.dump(
133
+ raw = Marshal.dump(
129
134
  schema: SCHEMA, fingerprint: fingerprint,
130
135
  cache: payload.cache, sources: payload.sources,
131
136
  digests: payload.digests, analyzed: payload.analyzed,
@@ -135,6 +140,7 @@ module Rigor
135
140
  missing: payload.missing,
136
141
  class_decls: payload.class_decls
137
142
  )
143
+ blob = Zlib::Deflate.deflate(raw)
138
144
  tmp = "#{@path}.#{Process.pid}.tmp"
139
145
  File.binwrite(tmp, blob)
140
146
  File.rename(tmp, @path)
@@ -20,7 +20,11 @@ module Rigor
20
20
  # structural contract.
21
21
  class RbsCacheProducer
22
22
  def self.fetch(loader:, store:)
23
- descriptor = RbsDescriptor.build(loader)
23
+ # ADR-54 WD4 — the descriptor is identical for every producer
24
+ # consulting the same loader (same sig files, same libraries),
25
+ # so the loader memoises one build per process instead of
26
+ # re-digesting every .rbs file once per producer.
27
+ descriptor = loader.rbs_cache_descriptor
24
28
  store.fetch_or_compute(producer_id: self::PRODUCER_ID, params: {}, descriptor: descriptor) do
25
29
  compute(loader)
26
30
  end
@@ -29,7 +29,8 @@ module Rigor
29
29
 
30
30
  def self.file_entries(loader)
31
31
  roots = loader.signature_paths +
32
- Rigor::Environment::RbsLoader.vendored_gem_sig_paths
32
+ Rigor::Environment::RbsLoader.vendored_gem_sig_paths +
33
+ Rigor::Environment::RbsLoader.core_overlay_sig_paths
33
34
  roots.flat_map do |root|
34
35
  next [] unless root.directory?
35
36
 
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "json"
6
6
  require "monitor"
7
7
  require "securerandom"
8
+ require "zlib"
8
9
 
9
10
  require_relative "descriptor"
10
11
 
@@ -17,17 +18,27 @@ module Rigor
17
18
  # and nothing else.
18
19
  #
19
20
  # Read failures (missing file, bad magic, format-version mismatch,
20
- # corrupt SHA-256 trailer, unmarshal-able payload) are silently
21
- # treated as cache misses; the producer block reruns and the
22
- # next write replaces the bad entry. The trailing SHA-256 catches
23
- # accidental corruption (partial writes, FS errors); it is **not**
24
- # a security boundary, per ADR-2's trusted-gem trust model.
21
+ # corrupt SHA-256 trailer, un-inflatable or unmarshal-able payload)
22
+ # are silently treated as cache misses; the producer block reruns
23
+ # and the next write replaces the bad entry. The trailing SHA-256
24
+ # catches accidental corruption (partial writes, FS errors); it is
25
+ # **not** a security boundary, per ADR-2's trusted-gem trust model.
25
26
  class Store # rubocop:disable Metrics/ClassLength
27
+ # On-disk byte-layout version. Bumped on incompatible format
28
+ # changes (independent of {Descriptor::SCHEMA_VERSION}, which
29
+ # covers the descriptor schema rather than the byte layout).
30
+ # v2 (ADR-54 WD2): the value payload is zlib-deflated on write
31
+ # and inflated on read — Marshal blobs compress to 13–16 % at
32
+ # an inflate cost an order of magnitude below their
33
+ # `Marshal.load`. v1 entries fail the header check and read as
34
+ # silent misses; the `schema_version.txt` marker additionally
35
+ # carries this version, so the first writable run after a bump
36
+ # clears the root and reclaims the unreadable bytes.
37
+ FORMAT_VERSION = 2
38
+
26
39
  # Header literal: 5-byte ASCII magic, 1-byte separator, 1-byte
27
- # format version. Bumped on incompatible on-disk format changes
28
- # (independent of {Descriptor::SCHEMA_VERSION}, which covers
29
- # the descriptor schema rather than the byte layout).
30
- HEADER = "RIGOR\x00\x01".b.freeze
40
+ # format version.
41
+ HEADER = "RIGOR\x00#{FORMAT_VERSION.chr}".b.freeze
31
42
 
32
43
  VALID_PRODUCER_ID = /\A[a-z][a-z0-9._-]*\z/
33
44
 
@@ -48,6 +59,7 @@ module Rigor
48
59
  @root = root.to_s.dup.freeze
49
60
  @read_only = read_only
50
61
  @max_bytes = max_bytes&.then { |n| Integer(n) }
62
+ @schema_version_ensured = false
51
63
  @hits = 0
52
64
  @misses = 0
53
65
  @writes = 0
@@ -107,6 +119,18 @@ module Rigor
107
119
  # When the root does not exist or has no schema-version
108
120
  # marker, `schema_version` is nil and the producer list is
109
121
  # empty.
122
+ # The `schema_version.txt` marker content. Covers BOTH
123
+ # invalidation axes: the descriptor schema and the on-disk byte
124
+ # layout ({FORMAT_VERSION}, ADR-54). A format bump leaves the
125
+ # old entries permanently unreadable (header mismatch → miss)
126
+ # but, alone, would never reclaim their bytes — they can sit
127
+ # below the eviction cap forever. Folding the format version
128
+ # into the marker routes the bump through the established
129
+ # clear-the-root path instead.
130
+ def self.schema_marker_value
131
+ "#{Descriptor::SCHEMA_VERSION}.#{FORMAT_VERSION}"
132
+ end
133
+
110
134
  def self.disk_inventory(root:)
111
135
  root_s = root.to_s
112
136
  marker = File.join(root_s, "schema_version.txt")
@@ -348,11 +372,15 @@ module Rigor
348
372
  LOAD_FAILED = Object.new.freeze
349
373
  private_constant :LOAD_FAILED
350
374
 
375
+ # Inflates the stored value payload (ADR-54 WD2), then hands the
376
+ # raw bytes to the deserialiser. Any failure — corrupt deflate
377
+ # stream included — reads as a miss.
351
378
  def safe_load(bytes, deserialize)
379
+ raw = Zlib::Inflate.inflate(bytes)
352
380
  if deserialize
353
- deserialize.call(bytes)
381
+ deserialize.call(raw)
354
382
  else
355
- Marshal.load(bytes) # rubocop:disable Security/MarshalLoad
383
+ Marshal.load(raw) # rubocop:disable Security/MarshalLoad
356
384
  end
357
385
  rescue StandardError
358
386
  LOAD_FAILED
@@ -362,7 +390,7 @@ module Rigor
362
390
  FileUtils.mkdir_p(File.dirname(path))
363
391
 
364
392
  descriptor_bytes = descriptor.to_canonical_bytes
365
- value_bytes = serialize_value(value, serialize)
393
+ value_bytes = Zlib::Deflate.deflate(serialize_value(value, serialize))
366
394
 
367
395
  body = +"".b
368
396
  body << HEADER
@@ -408,10 +436,15 @@ module Rigor
408
436
  # never collides with a read under the old). The next
409
437
  # writable run will repair the cache.
410
438
  return if @read_only
439
+ # The marker is process-stable; one check per Store is
440
+ # enough (a benign double-check under a thread race just
441
+ # repeats idempotent work).
442
+ return if @schema_version_ensured
411
443
 
444
+ @schema_version_ensured = true
412
445
  FileUtils.mkdir_p(@root)
413
446
  marker = File.join(@root, "schema_version.txt")
414
- current = Descriptor::SCHEMA_VERSION.to_s
447
+ current = self.class.schema_marker_value
415
448
 
416
449
  if File.file?(marker)
417
450
  on_disk = File.read(marker).strip