rigortype 0.1.18 → 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 -224
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
- 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 +169 -23
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
- data/lib/rigor/analysis/check_rules.rb +266 -63
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
- data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
- data/lib/rigor/analysis/runner.rb +58 -21
- data/lib/rigor/analysis/worker_session.rb +21 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +3 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +57 -1
- data/lib/rigor/environment/rbs_loader.rb +25 -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 +1052 -43
- data/lib/rigor/inference/macro_block_self_type.rb +2 -2
- 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/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 +72 -1
- 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 +270 -37
- data/lib/rigor/inference/scope_indexer.rb +696 -25
- data/lib/rigor/inference/statement_evaluator.rb +963 -16
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/base.rb +235 -79
- 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 +59 -14
- data/lib/rigor/plugin/registry.rb +12 -11
- data/lib/rigor/scope/discovery_index.rb +2 -0
- data/lib/rigor/scope.rb +132 -6
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +29 -0
- data/lib/rigor/version.rb +1 -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.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- 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.rb +2 -13
- 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.rb +25 -0
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/plugin/base.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +10 -1
- 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-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 +7 -2
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -155,6 +155,7 @@ module Rigor
|
|
|
155
155
|
@cached_plugin_prepare_diagnostics = [].freeze
|
|
156
156
|
@project_discovered_classes = {}.freeze
|
|
157
157
|
@project_discovered_def_nodes = {}.freeze
|
|
158
|
+
@project_discovered_singleton_def_nodes = {}.freeze
|
|
158
159
|
@project_discovered_def_sources = {}.freeze
|
|
159
160
|
@project_discovered_superclasses = {}.freeze
|
|
160
161
|
@project_discovered_includes = {}.freeze
|
|
@@ -457,7 +458,7 @@ module Rigor
|
|
|
457
458
|
# discovery tables at their frozen-empty constructor defaults
|
|
458
459
|
# (the bundle carries `nil` for them, matching the original
|
|
459
460
|
# adopt path that never touched them).
|
|
460
|
-
def apply_pre_passes_result(result)
|
|
461
|
+
def apply_pre_passes_result(result) # rubocop:disable Metrics/AbcSize
|
|
461
462
|
@plugin_registry = result.plugin_registry
|
|
462
463
|
@dependency_source_index = result.dependency_source_index
|
|
463
464
|
@cached_plugin_prepare_diagnostics = result.cached_plugin_prepare_diagnostics
|
|
@@ -466,6 +467,9 @@ module Rigor
|
|
|
466
467
|
@pre_eval_diagnostics_from_scanner = result.pre_eval_diagnostics_from_scanner
|
|
467
468
|
@project_discovered_classes = result.discovered_classes if result.discovered_classes
|
|
468
469
|
@project_discovered_def_nodes = result.discovered_def_nodes if result.discovered_def_nodes
|
|
470
|
+
if result.discovered_singleton_def_nodes
|
|
471
|
+
@project_discovered_singleton_def_nodes = result.discovered_singleton_def_nodes
|
|
472
|
+
end
|
|
469
473
|
@project_discovered_def_sources = result.discovered_def_sources if result.discovered_def_sources
|
|
470
474
|
@project_discovered_superclasses = result.discovered_superclasses if result.discovered_superclasses
|
|
471
475
|
@project_discovered_includes = result.discovered_includes if result.discovered_includes
|
|
@@ -636,30 +640,44 @@ module Rigor
|
|
|
636
640
|
# `#diagnostics_for_file` or declared a `node_rule` are visited
|
|
637
641
|
# (`contribution_index.for_file_diagnostics`); a skipped plugin's
|
|
638
642
|
# two hooks could only have returned `[]`.
|
|
639
|
-
def plugin_emitted_diagnostics(path, root, scope)
|
|
643
|
+
def plugin_emitted_diagnostics(path, root, scope, node_results)
|
|
640
644
|
return [] if @plugin_registry.empty?
|
|
641
645
|
|
|
642
|
-
# ADR-52 WD4 — one engine-owned AST walk per file dispatches each
|
|
643
|
-
# node to every matching (plugin, rule); the per-plugin results
|
|
644
|
-
# are bucketed in registry order so emission stays plugin-major
|
|
645
|
-
# (byte-identical with the old per-plugin walk).
|
|
646
|
-
node_results = node_rule_results_by_plugin(path, root, scope)
|
|
647
|
-
|
|
648
646
|
@plugin_registry.contribution_index.for_file_diagnostics.flat_map do |plugin|
|
|
649
647
|
collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
|
|
650
648
|
end
|
|
651
649
|
end
|
|
652
650
|
|
|
653
|
-
|
|
651
|
+
# ADR-52 WD4 + ADR-53 B4 — one engine-owned AST walk per file
|
|
652
|
+
# dispatches each node to every matching (plugin, rule) AND drives
|
|
653
|
+
# the built-in node collectors (`node_collectors`), so the file is
|
|
654
|
+
# walked once for both. The per-plugin results are bucketed in
|
|
655
|
+
# registry order so plugin emission stays plugin-major
|
|
656
|
+
# (byte-identical with the old per-plugin walk); the collectors are
|
|
657
|
+
# populated in place for `diagnose` to consume.
|
|
658
|
+
#
|
|
659
|
+
# When no plugin declares a node rule, the walk still runs to drive
|
|
660
|
+
# the collectors (the converged path replaces the standalone
|
|
661
|
+
# `RuleWalk.run`); `node_collectors` nil means a caller that does
|
|
662
|
+
# not need built-in collection from this walk.
|
|
663
|
+
def node_rule_results_by_plugin(path, root, scope, node_collectors, scope_index)
|
|
654
664
|
walk = @plugin_registry.node_rule_walk
|
|
655
|
-
|
|
665
|
+
driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
|
|
666
|
+
return {}.compare_by_identity if walk.empty? && driver.nil?
|
|
656
667
|
|
|
657
|
-
results = walk.diagnostics_for_file(
|
|
668
|
+
results = walk.diagnostics_for_file(
|
|
669
|
+
path: path, scope: scope, root: root, collector_driver: driver
|
|
670
|
+
)
|
|
671
|
+
CheckRules.shadow_verify_converged_collectors(path, root, scope_index, node_collectors) if shadow_rule_walk?
|
|
658
672
|
results.each_with_object({}.compare_by_identity) do |result, by_plugin|
|
|
659
673
|
by_plugin[result.plugin] = result
|
|
660
674
|
end
|
|
661
675
|
end
|
|
662
676
|
|
|
677
|
+
def shadow_rule_walk?
|
|
678
|
+
ENV.fetch("RIGOR_SHADOW_RULE_WALK", nil)
|
|
679
|
+
end
|
|
680
|
+
|
|
663
681
|
def collect_plugin_diagnostics(plugin, path, root, scope, node_result)
|
|
664
682
|
raw = Array(plugin.diagnostics_for_file(path: path, scope: scope, root: root))
|
|
665
683
|
# A node-rule context/rule raise isolates the whole plugin's
|
|
@@ -807,6 +825,9 @@ module Rigor
|
|
|
807
825
|
tables = {}
|
|
808
826
|
tables[:discovered_classes] = @project_discovered_classes unless @project_discovered_classes.empty?
|
|
809
827
|
tables[:discovered_def_nodes] = @project_discovered_def_nodes unless @project_discovered_def_nodes.empty?
|
|
828
|
+
unless @project_discovered_singleton_def_nodes.empty?
|
|
829
|
+
tables[:discovered_singleton_def_nodes] = @project_discovered_singleton_def_nodes
|
|
830
|
+
end
|
|
810
831
|
tables[:discovered_def_sources] = @project_discovered_def_sources unless @project_discovered_def_sources.empty?
|
|
811
832
|
unless @project_discovered_superclasses.empty?
|
|
812
833
|
tables[:discovered_superclasses] = @project_discovered_superclasses
|
|
@@ -861,15 +882,8 @@ module Rigor
|
|
|
861
882
|
self_call_record = with_self_call_recording(path) do
|
|
862
883
|
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
863
884
|
end
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
root: parse_result.value,
|
|
867
|
-
scope_index: index,
|
|
868
|
-
self_call_misses: self_call_record ? self_call_record.calls : [],
|
|
869
|
-
comments: parse_result.comments,
|
|
870
|
-
disabled_rules: @configuration.disabled_rules
|
|
871
|
-
)
|
|
872
|
-
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
885
|
+
self_call_misses = self_call_record ? self_call_record.calls : []
|
|
886
|
+
diagnostics = rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
|
|
873
887
|
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
874
888
|
rescue Errno::ENOENT => e
|
|
875
889
|
[
|
|
@@ -893,6 +907,28 @@ module Rigor
|
|
|
893
907
|
]
|
|
894
908
|
end
|
|
895
909
|
|
|
910
|
+
# ADR-53 B4 — the built-in node collectors and the plugin node rules
|
|
911
|
+
# share ONE traversal of the file. The collectors are built here (they
|
|
912
|
+
# need the completed `index`) and populated by the converged plugin
|
|
913
|
+
# walk; `node_results` carries the per-plugin node-rule output. Both
|
|
914
|
+
# the built-in `diagnose` output and the plugin diagnostics are then
|
|
915
|
+
# built from that single walk's results.
|
|
916
|
+
def rule_and_plugin_diagnostics(path, parse_result, scope, index, self_call_misses)
|
|
917
|
+
root = parse_result.value
|
|
918
|
+
node_collectors = CheckRules.build_node_collectors(path, index)
|
|
919
|
+
node_results = node_rule_results_by_plugin(path, root, scope, node_collectors, index)
|
|
920
|
+
diagnostics = CheckRules.diagnose(
|
|
921
|
+
path: path,
|
|
922
|
+
root: root,
|
|
923
|
+
scope_index: index,
|
|
924
|
+
self_call_misses: self_call_misses,
|
|
925
|
+
comments: parse_result.comments,
|
|
926
|
+
disabled_rules: @configuration.disabled_rules,
|
|
927
|
+
node_collectors: node_collectors
|
|
928
|
+
)
|
|
929
|
+
diagnostics + plugin_emitted_diagnostics(path, root, scope, node_results)
|
|
930
|
+
end
|
|
931
|
+
|
|
896
932
|
# ADR-24 slice 4a — runs `block` (the typing pass) with the self-call
|
|
897
933
|
# recorder active when either the test-only `record_self_calls:` flag is
|
|
898
934
|
# set or the `call.self-undefined-method` rule resolves to a firing
|
|
@@ -928,7 +964,8 @@ module Rigor
|
|
|
928
964
|
else
|
|
929
965
|
Configuration::SeverityProfile.resolve(
|
|
930
966
|
rule: rule, authored_severity: :warning,
|
|
931
|
-
profile: @configuration.severity_profile, overrides: @configuration.severity_overrides
|
|
967
|
+
profile: @configuration.severity_profile, overrides: @configuration.severity_overrides,
|
|
968
|
+
bleeding_edge_overrides: @configuration.bleeding_edge_severity_overrides
|
|
932
969
|
) != :off
|
|
933
970
|
end
|
|
934
971
|
end
|
|
@@ -175,14 +175,18 @@ module Rigor
|
|
|
175
175
|
|
|
176
176
|
scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
|
|
177
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)
|
|
178
181
|
diagnostics = CheckRules.diagnose(
|
|
179
182
|
path: path,
|
|
180
183
|
root: parse_result.value,
|
|
181
184
|
scope_index: index,
|
|
182
185
|
comments: parse_result.comments,
|
|
183
|
-
disabled_rules: @configuration.disabled_rules
|
|
186
|
+
disabled_rules: @configuration.disabled_rules,
|
|
187
|
+
node_collectors: node_collectors
|
|
184
188
|
)
|
|
185
|
-
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope)
|
|
189
|
+
diagnostics += plugin_emitted_diagnostics(path, parse_result.value, scope, node_results)
|
|
186
190
|
diagnostics + explain_diagnostics(path, parse_result.value, scope)
|
|
187
191
|
rescue Errno::ENOENT => e
|
|
188
192
|
[analyzer_error(path, e.message)]
|
|
@@ -294,24 +298,30 @@ module Rigor
|
|
|
294
298
|
)
|
|
295
299
|
end
|
|
296
300
|
|
|
297
|
-
def plugin_emitted_diagnostics(path, root, scope)
|
|
301
|
+
def plugin_emitted_diagnostics(path, root, scope, node_results)
|
|
298
302
|
return [] if @plugin_registry.empty?
|
|
299
303
|
|
|
300
|
-
# ADR-52 WD4 — single engine-owned node-rule walk per file; the
|
|
301
|
-
# results are bucketed per plugin (registry order) so emission
|
|
302
|
-
# stays plugin-major and byte-identical with the per-plugin walk.
|
|
303
|
-
node_results = node_rule_results_by_plugin(path, root, scope)
|
|
304
|
-
|
|
305
304
|
@plugin_registry.plugins.flat_map do |plugin|
|
|
306
305
|
collect_plugin_diagnostics(plugin, path, root, scope, node_results[plugin])
|
|
307
306
|
end
|
|
308
307
|
end
|
|
309
308
|
|
|
310
|
-
|
|
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)
|
|
311
315
|
walk = @plugin_registry.node_rule_walk
|
|
312
|
-
|
|
316
|
+
driver = node_collectors && CheckRules.node_collector_driver(node_collectors)
|
|
317
|
+
return {}.compare_by_identity if walk.empty? && driver.nil?
|
|
313
318
|
|
|
314
|
-
results = walk.diagnostics_for_file(
|
|
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
|
|
315
325
|
results.each_with_object({}.compare_by_identity) do |result, by_plugin|
|
|
316
326
|
by_plugin[result.plugin] = result
|
|
317
327
|
end
|
|
@@ -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
|
|
@@ -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
|
|