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.
- checksums.yaml +4 -4
- data/README.md +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- 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`
|
|
44
|
-
# Ractor
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
data/lib/rigor/cache/store.rb
CHANGED
|
@@ -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)
|
|
21
|
-
# treated as cache misses; the producer block reruns
|
|
22
|
-
# next write replaces the bad entry. The trailing SHA-256
|
|
23
|
-
# accidental corruption (partial writes, FS errors); it is
|
|
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.
|
|
28
|
-
|
|
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(
|
|
381
|
+
deserialize.call(raw)
|
|
354
382
|
else
|
|
355
|
-
Marshal.load(
|
|
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 =
|
|
447
|
+
current = self.class.schema_marker_value
|
|
415
448
|
|
|
416
449
|
if File.file?(marker)
|
|
417
450
|
on_disk = File.read(marker).strip
|