rigortype 0.3.4 → 0.3.6
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 +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
data/lib/rigor/configuration.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "bleeding_edge"
|
|
|
6
6
|
require_relative "ci_detector"
|
|
7
7
|
require_relative "configuration/dependencies"
|
|
8
8
|
require_relative "configuration/severity_profile"
|
|
9
|
+
require_relative "configuration_error"
|
|
9
10
|
require_relative "effects/entry_points"
|
|
10
11
|
require_relative "effects/label"
|
|
11
12
|
require_relative "effects/method_key"
|
|
@@ -358,10 +359,10 @@ module Rigor
|
|
|
358
359
|
# {CLI::CheckCommand#load_check_configuration}).
|
|
359
360
|
def self.load_with_includes(path, visited: Set.new)
|
|
360
361
|
absolute = File.expand_path(path)
|
|
361
|
-
raise
|
|
362
|
+
raise ConfigurationError, "circular include: #{absolute}" if visited.include?(absolute)
|
|
362
363
|
|
|
363
|
-
raw =
|
|
364
|
-
raise
|
|
364
|
+
raw = read_yaml(absolute)
|
|
365
|
+
raise ConfigurationError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
|
|
365
366
|
|
|
366
367
|
base_dir = File.dirname(absolute)
|
|
367
368
|
includes = Array(raw.delete("includes") || [])
|
|
@@ -370,6 +371,17 @@ module Rigor
|
|
|
370
371
|
merge_includes(data, includes, base_dir, next_visited)
|
|
371
372
|
end
|
|
372
373
|
|
|
374
|
+
# #433's sibling: a typo in the file the user is about to be told to fix is a configuration mistake
|
|
375
|
+
# like any other, and escaped as a `Psych::SyntaxError` backtrace naming a file inside Ruby's stdlib.
|
|
376
|
+
# Psych's own `#message` embeds the path in a parenthesised prefix that reads badly after `rigor: `,
|
|
377
|
+
# so the position is re-rendered in the `path:line:column` form the rest of Rigor's output uses.
|
|
378
|
+
def self.read_yaml(absolute)
|
|
379
|
+
YAML.safe_load_file(absolute, aliases: false) || {}
|
|
380
|
+
rescue Psych::SyntaxError => e
|
|
381
|
+
detail = [e.problem, e.context].compact.join(" ")
|
|
382
|
+
raise ConfigurationError, "#{absolute}:#{e.line}:#{e.column}: not valid YAML: #{detail}"
|
|
383
|
+
end
|
|
384
|
+
|
|
373
385
|
def self.merge_includes(data, includes, base_dir, visited)
|
|
374
386
|
return data if includes.empty?
|
|
375
387
|
|
|
@@ -377,7 +389,7 @@ module Rigor
|
|
|
377
389
|
includes.each do |inc|
|
|
378
390
|
inc_path = File.expand_path(inc.to_s, base_dir)
|
|
379
391
|
unless File.exist?(inc_path)
|
|
380
|
-
raise
|
|
392
|
+
raise ConfigurationError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
|
|
381
393
|
end
|
|
382
394
|
|
|
383
395
|
accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
|
|
@@ -737,7 +749,7 @@ module Rigor
|
|
|
737
749
|
|
|
738
750
|
gate = value.to_s.to_sym
|
|
739
751
|
unless VALID_EFFECTS_GATES.include?(gate)
|
|
740
|
-
raise
|
|
752
|
+
raise ConfigurationError,
|
|
741
753
|
"effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
|
|
742
754
|
end
|
|
743
755
|
|
|
@@ -756,7 +768,7 @@ module Rigor
|
|
|
756
768
|
entries.each do |entry|
|
|
757
769
|
next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
|
|
758
770
|
|
|
759
|
-
raise
|
|
771
|
+
raise ConfigurationError,
|
|
760
772
|
"effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
|
|
761
773
|
"name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
|
|
762
774
|
"anything carrying a path or glob character is treated as a file glob instead)"
|
|
@@ -768,7 +780,7 @@ module Rigor
|
|
|
768
780
|
def coerce_effects_tolerated(value)
|
|
769
781
|
labels = Array(value).map(&:to_s)
|
|
770
782
|
labels.each do |label|
|
|
771
|
-
raise
|
|
783
|
+
raise ConfigurationError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
|
|
772
784
|
Effects::Label.valid?(label)
|
|
773
785
|
end
|
|
774
786
|
labels.uniq.sort.freeze
|
|
@@ -795,7 +807,7 @@ module Rigor
|
|
|
795
807
|
def coerce_effects_labels(value)
|
|
796
808
|
labels = Array(value).map(&:to_s)
|
|
797
809
|
labels.each do |label|
|
|
798
|
-
raise
|
|
810
|
+
raise ConfigurationError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
|
|
799
811
|
Effects::Label.valid?(label)
|
|
800
812
|
end
|
|
801
813
|
labels.uniq.sort.freeze
|
|
@@ -809,7 +821,7 @@ module Rigor
|
|
|
809
821
|
value.each_with_object({}) do |(key, labels), out|
|
|
810
822
|
name = key.to_s
|
|
811
823
|
unless Effects::MethodKey.valid?(name)
|
|
812
|
-
raise
|
|
824
|
+
raise ConfigurationError,
|
|
813
825
|
"effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
|
|
814
826
|
end
|
|
815
827
|
|
|
@@ -826,16 +838,16 @@ module Rigor
|
|
|
826
838
|
|
|
827
839
|
def coerce_effects_envelope(entry, index)
|
|
828
840
|
where = "effects.envelopes[#{index}]"
|
|
829
|
-
raise
|
|
841
|
+
raise ConfigurationError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
|
|
830
842
|
|
|
831
843
|
match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
|
|
832
844
|
namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
|
|
833
845
|
if match.nil? == namespace.nil?
|
|
834
|
-
raise
|
|
835
|
-
|
|
846
|
+
raise ConfigurationError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
|
|
847
|
+
"(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
|
|
836
848
|
end
|
|
837
849
|
unless entry.key?("effect")
|
|
838
|
-
raise
|
|
850
|
+
raise ConfigurationError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
|
|
839
851
|
end
|
|
840
852
|
|
|
841
853
|
{
|
|
@@ -848,14 +860,14 @@ module Rigor
|
|
|
848
860
|
return nil if value.nil?
|
|
849
861
|
|
|
850
862
|
selector = value.to_s
|
|
851
|
-
raise
|
|
863
|
+
raise ConfigurationError, "#{where} is empty" if selector.strip.empty?
|
|
852
864
|
|
|
853
865
|
selector.freeze
|
|
854
866
|
end
|
|
855
867
|
|
|
856
868
|
def coerce_effect_label_list(labels, where)
|
|
857
869
|
labels.map(&:to_s).each do |label|
|
|
858
|
-
raise
|
|
870
|
+
raise ConfigurationError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
|
|
859
871
|
Effects::Label.valid?(label)
|
|
860
872
|
end.uniq.sort.freeze
|
|
861
873
|
end
|
|
@@ -886,7 +898,7 @@ module Rigor
|
|
|
886
898
|
when Hash
|
|
887
899
|
entry.to_h { |k, v| [k.to_s, v] }.freeze
|
|
888
900
|
else
|
|
889
|
-
raise
|
|
901
|
+
raise ConfigurationError,
|
|
890
902
|
"plugin configuration entry must be a String or Hash, got #{entry.inspect}"
|
|
891
903
|
end
|
|
892
904
|
end
|
|
@@ -903,7 +915,7 @@ module Rigor
|
|
|
903
915
|
def coerce_target_ruby(value)
|
|
904
916
|
s = value.to_s
|
|
905
917
|
unless s.match?(TARGET_RUBY_FORMAT)
|
|
906
|
-
raise
|
|
918
|
+
raise ConfigurationError,
|
|
907
919
|
"target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
|
|
908
920
|
end
|
|
909
921
|
|
|
@@ -924,11 +936,11 @@ module Rigor
|
|
|
924
936
|
# disabling parallelism.
|
|
925
937
|
def coerce_parallel_workers(value)
|
|
926
938
|
integer = Integer(value)
|
|
927
|
-
raise
|
|
939
|
+
raise ConfigurationError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
|
|
928
940
|
|
|
929
941
|
integer
|
|
930
942
|
rescue TypeError, ArgumentError => e
|
|
931
|
-
raise
|
|
943
|
+
raise ConfigurationError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
|
|
932
944
|
end
|
|
933
945
|
|
|
934
946
|
# ADR-22 WD2 (b) — `baseline: <path>` activates the file; `baseline: false` is the explicit-disable form
|
|
@@ -955,7 +967,7 @@ module Rigor
|
|
|
955
967
|
def coerce_network_policy(value)
|
|
956
968
|
sym = value.to_sym
|
|
957
969
|
unless VALID_NETWORK_POLICIES.include?(sym)
|
|
958
|
-
raise
|
|
970
|
+
raise ConfigurationError,
|
|
959
971
|
"plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
|
|
960
972
|
end
|
|
961
973
|
|
|
@@ -967,7 +979,7 @@ module Rigor
|
|
|
967
979
|
def coerce_severity_profile(value)
|
|
968
980
|
sym = value.to_sym
|
|
969
981
|
unless SeverityProfile::VALID_PROFILES.include?(sym)
|
|
970
|
-
raise
|
|
982
|
+
raise ConfigurationError,
|
|
971
983
|
"severity_profile must be one of " \
|
|
972
984
|
"#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
|
|
973
985
|
end
|
|
@@ -980,7 +992,7 @@ module Rigor
|
|
|
980
992
|
# {SeverityProfile::VALID_SEVERITIES} symbols (`:error` / `:warning` / `:info` / `:off`). Unknown
|
|
981
993
|
# severities raise; unknown rule ids are silently kept (the override is inert until the rule lands).
|
|
982
994
|
def coerce_severity_overrides(value)
|
|
983
|
-
raise
|
|
995
|
+
raise ConfigurationError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
|
|
984
996
|
|
|
985
997
|
value.to_h do |k, v|
|
|
986
998
|
# YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false` as booleans, so a user who wrote `off` (a
|
|
@@ -988,7 +1000,7 @@ module Rigor
|
|
|
988
1000
|
# `to_sym` blows up with a backtrace.
|
|
989
1001
|
unless v.is_a?(String) || v.is_a?(Symbol)
|
|
990
1002
|
hint = v == false ? %( — did you mean the string "off"?) : ""
|
|
991
|
-
raise
|
|
1003
|
+
raise ConfigurationError,
|
|
992
1004
|
"severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
|
|
993
1005
|
"Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
|
|
994
1006
|
"(e.g. \"off\")."
|
|
@@ -996,7 +1008,7 @@ module Rigor
|
|
|
996
1008
|
|
|
997
1009
|
sym = v.to_sym
|
|
998
1010
|
unless SeverityProfile::VALID_SEVERITIES.include?(sym)
|
|
999
|
-
raise
|
|
1011
|
+
raise ConfigurationError,
|
|
1000
1012
|
"severity_overrides[#{k.inspect}] must be one of " \
|
|
1001
1013
|
"#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
|
|
1002
1014
|
end
|
|
@@ -1016,7 +1028,7 @@ module Rigor
|
|
|
1016
1028
|
when Array then { "mode" => "list", "ids" => freeze_ids(value) }
|
|
1017
1029
|
when Hash then coerce_bleeding_edge_hash(value)
|
|
1018
1030
|
else
|
|
1019
|
-
raise
|
|
1031
|
+
raise ConfigurationError,
|
|
1020
1032
|
"bleeding_edge must be true, false, a list of feature ids, " \
|
|
1021
1033
|
"or { all: true, except: [...] }, got #{value.inspect}"
|
|
1022
1034
|
end.freeze
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# A mistake in the project's configuration — a value `Configuration` cannot proceed on (the tier-2
|
|
5
|
+
# failures `docs/internal-spec/config.md` enumerates), or a `.rigor.yml` key the loader accepted whose
|
|
6
|
+
# meaning only resolves later, like an `effects.snapshot.reach:` preset name.
|
|
7
|
+
#
|
|
8
|
+
# It exists so the CLI can tell **the user got the file wrong** apart from **Rigor got itself wrong**.
|
|
9
|
+
# Both used to reach the terminal as an uncaught `ArgumentError` with a thirty-frame backtrace naming a
|
|
10
|
+
# file inside `lib/rigor/`, which reads as a crash: the reader's first move is to file a bug, not to
|
|
11
|
+
# fix the key the message already named (#433). {CLI#run} rescues this class and renders it as a single
|
|
12
|
+
# `rigor:` line.
|
|
13
|
+
#
|
|
14
|
+
# It stays an `ArgumentError` subclass deliberately. `Configuration` raised `ArgumentError` from every
|
|
15
|
+
# coercion since the beginning, that is the documented tier-2 contract, and callers outside the CLI —
|
|
16
|
+
# the language server, embedders, the suite — rescue it by that name. Narrowing the class is a
|
|
17
|
+
# presentation change, not a contract change.
|
|
18
|
+
class ConfigurationError < ArgumentError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -128,7 +128,7 @@ module Rigor
|
|
|
128
128
|
# {Accumulator#record} is what stops it paying for the `Data` and the origin lookup below.
|
|
129
129
|
return if accumulator.recorded?(node)
|
|
130
130
|
|
|
131
|
-
dynamic =
|
|
131
|
+
dynamic = dynamic_receiver?(receiver)
|
|
132
132
|
class_name, kind = descriptor_for(receiver)
|
|
133
133
|
accumulator.record(
|
|
134
134
|
node,
|
|
@@ -160,9 +160,46 @@ module Rigor
|
|
|
160
160
|
when Type::HashShape then ["Hash", :instance]
|
|
161
161
|
when Type::Constant then [receiver.value.class.name, :instance]
|
|
162
162
|
when Type::Dynamic then descriptor_for(receiver.static_facet)
|
|
163
|
+
when Type::Union then union_descriptor(receiver)
|
|
163
164
|
end
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# Whether the typer's verdict on this receiver leaves its class a guess. A bare `Dynamic` is the
|
|
168
|
+
# original case; a union carrying a `Dynamic` arm is the same knowledge in a different shape, and
|
|
169
|
+
# before #455 it was the shape that said nothing — no class, no edge, and no taint, so the summary
|
|
170
|
+
# read *exhaustive* while an arm of its receiver was admittedly unknown.
|
|
171
|
+
def dynamic_receiver?(receiver)
|
|
172
|
+
return true if receiver.is_a?(Type::Dynamic)
|
|
173
|
+
|
|
174
|
+
receiver.is_a?(Type::Union) && receiver.members.any?(Type::Dynamic)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A union projects to a class exactly when its non-nil arms all project to the SAME one (#455).
|
|
178
|
+
#
|
|
179
|
+
# `T?` is the case that pays, and it is not a corner: ADR-58 contributes a declaration-sourced
|
|
180
|
+
# `nil` to every instance variable not written in `initialize`, so **every cross-method ivar read
|
|
181
|
+
# is a union** — `@group.save` reads `Group | nil` where the same `Group.new.save` two lines up
|
|
182
|
+
# reads `Group`. Without this arm the receiver had no class, so the plugin row never matched, the
|
|
183
|
+
# edge was dropped, and the site contributed nothing *while the summary still read exhaustive*.
|
|
184
|
+
# Whether the nil arm means the call happens at all is a question for `possible-nil-receiver`; it
|
|
185
|
+
# says nothing about what the call does when it does happen, which is the only question here.
|
|
186
|
+
#
|
|
187
|
+
# Arms that disagree (`File | StringIO`) still project to nothing. Answering with either one would
|
|
188
|
+
# state an effect no single execution need perform, and answering with both is a shape the record
|
|
189
|
+
# has no room for — one call site carries one receiver class.
|
|
190
|
+
def union_descriptor(receiver)
|
|
191
|
+
descriptors = receiver.members.reject { |member| nil_member?(member) }.map { |member| descriptor_for(member) }
|
|
192
|
+
first = descriptors.first
|
|
193
|
+
return nil if first.nil?
|
|
194
|
+
|
|
195
|
+
descriptors.all? { |descriptor| descriptor == first } ? first : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def nil_member?(member)
|
|
199
|
+
(member.is_a?(Type::Constant) && member.value.nil?) ||
|
|
200
|
+
(member.is_a?(Type::Nominal) && member.class_name == "NilClass")
|
|
201
|
+
end
|
|
202
|
+
|
|
166
203
|
# Fail-soft (WD13): the scan raising drops this file's summaries and never reaches `rigor check`.
|
|
167
204
|
def build(accumulator)
|
|
168
205
|
return FileCollection.empty(accumulator.path) if accumulator.root.nil?
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Where a method key's `def` is written, resolved from the file the key was already traced to
|
|
10
|
+
# (#435).
|
|
11
|
+
#
|
|
12
|
+
# A drift row names the file out of `Runner#effect_sources`, which rides the cached summary entry
|
|
13
|
+
# and is therefore free. The **line** is not in any value the effects surfaces hold: the discovery
|
|
14
|
+
# tables {EnvelopeCheck::Positions} reads are built by one Prism parse of every project file, which
|
|
15
|
+
# is exactly what [ADR-104](../../../docs/adr/104-effects-boot-slim-probe.md) removed from this
|
|
16
|
+
# command — a warm `rigor effects check` is fast *because* it never parses the project.
|
|
17
|
+
#
|
|
18
|
+
# So this parses the drift's own files and nothing else: an index is built the first time a row asks
|
|
19
|
+
# about a path, and a report with no rows builds none. The cost is proportional to the drift, not to
|
|
20
|
+
# the project — the {EnvelopeCheck::DeferredPositions} shape (#479) applied one layer up, and the
|
|
21
|
+
# reason it can be one layer up is that the caller already knows the file.
|
|
22
|
+
#
|
|
23
|
+
# It is deliberately not a second discovery pass. It answers `def`s and only `def`s: a key whose
|
|
24
|
+
# method has no Ruby `def` at all — a synthesized accessor — keeps the file and loses the line,
|
|
25
|
+
# which is the same degradation `Positions` makes when it falls back to the class's own source.
|
|
26
|
+
class DefinitionLines
|
|
27
|
+
TOPLEVEL = "<toplevel>"
|
|
28
|
+
private_constant :TOPLEVEL
|
|
29
|
+
|
|
30
|
+
NO_LINES = {}.freeze
|
|
31
|
+
private_constant :NO_LINES
|
|
32
|
+
|
|
33
|
+
# @param key [String] an effect unit key — `Tracer::Loud#emit`, `Net::HTTP.get`.
|
|
34
|
+
# @param path [String] the file the key was traced to.
|
|
35
|
+
# @return [Integer, nil] the `def`'s line, or nil when this file does not spell that key with a
|
|
36
|
+
# `def` — an unreadable file, a syntax error, and a synthesized method all land here.
|
|
37
|
+
def for(key:, path:)
|
|
38
|
+
index_for(path)[key]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def index_for(path)
|
|
44
|
+
@indexes ||= {}
|
|
45
|
+
@indexes[path] ||= build_index(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# First `def` wins: a file that reopens the same method twice has two lines and only one of them is
|
|
49
|
+
# where a reader starts.
|
|
50
|
+
def build_index(path)
|
|
51
|
+
result = Prism.parse_file(path.to_s)
|
|
52
|
+
return NO_LINES unless result.success?
|
|
53
|
+
|
|
54
|
+
{}.tap { |index| walk(result.value, [], singleton: false, index: index) }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
NO_LINES
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The nesting is tracked, never resolved: `class Tracer::Loud` inside `module Tracer` spells a key
|
|
60
|
+
# this cannot see, and answering it would need the constant resolution an engine-free path does not
|
|
61
|
+
# have. A key it cannot spell keeps its file, which is what the row printed before this class existed.
|
|
62
|
+
def walk(node, nesting, singleton:, index:)
|
|
63
|
+
case node
|
|
64
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
65
|
+
name = constant_name(node.constant_path)
|
|
66
|
+
return if name.nil?
|
|
67
|
+
|
|
68
|
+
walk_children(node.body, nesting + [name], singleton: false, index: index)
|
|
69
|
+
when Prism::SingletonClassNode
|
|
70
|
+
walk_children(node.body, nesting, singleton: true, index: index)
|
|
71
|
+
when Prism::DefNode
|
|
72
|
+
record(node, nesting, singleton: singleton, index: index)
|
|
73
|
+
walk_children(node.body, nesting, singleton: singleton, index: index)
|
|
74
|
+
else
|
|
75
|
+
walk_children(node, nesting, singleton: singleton, index: index)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def walk_children(node, nesting, singleton:, index:)
|
|
80
|
+
node&.compact_child_nodes&.each { |child| walk(child, nesting, singleton: singleton, index: index) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def record(node, nesting, singleton:, index:)
|
|
84
|
+
separator = singleton || node.receiver.is_a?(Prism::SelfNode) ? "." : "#"
|
|
85
|
+
owner = nesting.empty? ? TOPLEVEL : nesting.join("::")
|
|
86
|
+
key = "#{owner}#{separator}#{node.name}"
|
|
87
|
+
index[key] ||= node.location.start_line
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def constant_name(node)
|
|
91
|
+
case node
|
|
92
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
93
|
+
when Prism::ConstantPathNode then node.full_name
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../configuration_error"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
module Effects
|
|
5
7
|
# The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
|
|
@@ -30,9 +32,19 @@ module Rigor
|
|
|
30
32
|
# than becoming an unfindable key.
|
|
31
33
|
NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
32
34
|
|
|
35
|
+
# A plugin's registration is wrong: a malformed preset name, or two plugins claiming one name with
|
|
36
|
+
# different globs. Nothing the user wrote, so it never reaches them as a configuration error —
|
|
37
|
+
# {PluginFacts} downgrades it to a warning.
|
|
33
38
|
class Error < StandardError
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# The user's `effects.snapshot.reach:` names a preset no active plugin registered — the most common
|
|
42
|
+
# of these being chapter 03's `reach: [rails]` without `rigor-railties` in `plugins:` (#433). A
|
|
43
|
+
# {Rigor::ConfigurationError}, not an {Error}, because the offending text is in `.rigor.yml`: it is
|
|
44
|
+
# the CLI's to render as a `rigor:` line, not a crash.
|
|
45
|
+
class UnknownPreset < Rigor::ConfigurationError
|
|
46
|
+
end
|
|
47
|
+
|
|
36
48
|
@presets = {}
|
|
37
49
|
|
|
38
50
|
class << self
|
|
@@ -73,7 +85,8 @@ module Rigor
|
|
|
73
85
|
Array(presets).each { |preset| register(preset.name, preset.globs) }
|
|
74
86
|
end
|
|
75
87
|
|
|
76
|
-
# Every registered preset name, sorted. Empty
|
|
88
|
+
# Every registered preset name, sorted. Empty until a plugin declaring `effect_entry_points:`
|
|
89
|
+
# loads, which is what makes {availability} worth printing.
|
|
77
90
|
def names
|
|
78
91
|
@presets.keys.sort.freeze
|
|
79
92
|
end
|
|
@@ -83,6 +96,39 @@ module Rigor
|
|
|
83
96
|
@presets[name.to_s]
|
|
84
97
|
end
|
|
85
98
|
|
|
99
|
+
# The globs `name` stands for, or the configuration error that says what this project could have
|
|
100
|
+
# written instead.
|
|
101
|
+
#
|
|
102
|
+
# The existence check lives here and runs from the snapshot build rather than from
|
|
103
|
+
# `Configuration` — presets are registered by plugins, and the plugins load FROM the configuration
|
|
104
|
+
# being validated, so at load time the registry is still empty. See {name?} for the shape check
|
|
105
|
+
# that does run at load.
|
|
106
|
+
def resolve!(name)
|
|
107
|
+
globs_for(name) || raise(
|
|
108
|
+
UnknownPreset,
|
|
109
|
+
"effects.snapshot.reach names no registered entry-point preset: #{name.to_s.inspect} " \
|
|
110
|
+
"(#{availability})"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# What this project may write in `effects.snapshot.reach:`, as one parenthetical clause.
|
|
115
|
+
#
|
|
116
|
+
# Single-homed here because the answer is a property of the loaded plugin set rather than of any
|
|
117
|
+
# one message: the unregistered-preset error (#433) and the note `rigor effects update` prints
|
|
118
|
+
# when `reach:` is empty (#436) have to give the same answer, and neither can hard-code a list
|
|
119
|
+
# that moves with `plugins:`.
|
|
120
|
+
#
|
|
121
|
+
# @return [String]
|
|
122
|
+
def availability
|
|
123
|
+
if @presets.empty?
|
|
124
|
+
return "no plugin in this project registers an entry-point preset — a preset is named by the " \
|
|
125
|
+
"plugin that models the framework, so listing that plugin under `plugins:` is what " \
|
|
126
|
+
"registers one"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
"presets registered in this project: #{names.join(', ')}"
|
|
130
|
+
end
|
|
131
|
+
|
|
86
132
|
# Drops every registration. For specs only — production code registers and never unregisters.
|
|
87
133
|
def reset!
|
|
88
134
|
@presets = {}
|
|
@@ -83,6 +83,21 @@ module Rigor
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# {Positions} behind a thunk: the discovery tables — one Prism parse of every project file —
|
|
87
|
+
# are built on the first `.for`, which both judgments reach only once a finding is being
|
|
88
|
+
# constructed. A clean judgment, the common CI case, therefore forces no discovery and parses
|
|
89
|
+
# nothing; that is the whole point of this class existing rather than the pass forcing the
|
|
90
|
+
# tables up front.
|
|
91
|
+
class DeferredPositions
|
|
92
|
+
def initialize(&build)
|
|
93
|
+
@build = build
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def for(key)
|
|
97
|
+
(@positions ||= @build.call).for(key)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
86
101
|
NO_FINDINGS = [].freeze
|
|
87
102
|
private_constant :NO_FINDINGS
|
|
88
103
|
|
|
@@ -93,7 +108,9 @@ module Rigor
|
|
|
93
108
|
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
94
109
|
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
95
110
|
# to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
|
|
96
|
-
# @param positions [Positions] the discovery tables a finding's `def`
|
|
111
|
+
# @param positions [Positions, DeferredPositions] the discovery tables a finding's `def`
|
|
112
|
+
# position is read from — consulted only when a finding is built, so a deferred value's
|
|
113
|
+
# discovery force is reached exactly as often as a finding exists.
|
|
97
114
|
# @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
|
|
98
115
|
# the `--no-tolerated-effects` audit switch.
|
|
99
116
|
# @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
|
|
@@ -30,7 +30,16 @@ module Rigor
|
|
|
30
30
|
# receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
|
|
31
31
|
# `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
|
|
32
32
|
# whose failure to resolve is an `unresolved-self-call` taint rather than silence.
|
|
33
|
-
|
|
33
|
+
#
|
|
34
|
+
# `super_call` marks the edge a `super` contributes (#446). It carries the ENCLOSING unit's class and
|
|
35
|
+
# selector rather than a receiver's, and the propagator resolves it against the ancestry *above* that
|
|
36
|
+
# class with no closed-world override join — a different question from every other edge, which is why
|
|
37
|
+
# it is a field rather than a convention over the other three.
|
|
38
|
+
Edge = Data.define(:receiver_class, :kind, :selector, :self_call, :super_call) do
|
|
39
|
+
# Defaulted because every producer but the `super` one records an ordinary call, and an ordinary
|
|
40
|
+
# call is not a `super`.
|
|
41
|
+
def initialize(super_call: false, **) = super
|
|
42
|
+
end
|
|
34
43
|
|
|
35
44
|
NO_TABLE = {}.freeze
|
|
36
45
|
private_constant :NO_TABLE
|
|
@@ -144,17 +153,22 @@ module Rigor
|
|
|
144
153
|
end
|
|
145
154
|
|
|
146
155
|
# Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
|
|
147
|
-
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call`
|
|
148
|
-
# in it because two edges can otherwise agree on every other field
|
|
156
|
+
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` and
|
|
157
|
+
# `super_call` are in it because two edges can otherwise agree on every other field (`def emit; super;
|
|
158
|
+
# emit; end` records both), and `sort_by` is not stable.
|
|
149
159
|
def freeze_edges(table)
|
|
150
160
|
return NO_TABLE if table.empty?
|
|
151
161
|
|
|
152
162
|
table.transform_values do |list|
|
|
153
163
|
sorted = list.uniq
|
|
154
|
-
sorted.sort_by! { |edge|
|
|
164
|
+
sorted.sort_by! { |edge| edge_order(edge) }
|
|
155
165
|
sorted.freeze
|
|
156
166
|
end.freeze
|
|
157
167
|
end
|
|
168
|
+
|
|
169
|
+
def edge_order(edge)
|
|
170
|
+
[edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0, edge.super_call ? 1 : 0]
|
|
171
|
+
end
|
|
158
172
|
end
|
|
159
173
|
end
|
|
160
174
|
end
|