rigortype 0.3.2 → 0.3.4
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 +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label_set"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# `effects.tolerated:` at judgment time — the discharge policy, **per origin** (ADR-103 WD1 / WD14;
|
|
8
|
+
# normative in `docs/type-specification/effect-labels.md` § Discharge by policy).
|
|
9
|
+
#
|
|
10
|
+
# The rule the whole slice turns on: **a bundle is discharged when ANY of its labels is tolerated.**
|
|
11
|
+
# An origin is one callee or one construct, and its labels are what that one thing does; tolerating
|
|
12
|
+
# what the origin was *for* frees the transport it came with. `Logger#info` is `io` + `telemetry`, so
|
|
13
|
+
# `tolerated: [telemetry]` discharges the whole bundle — the project has said "logging is fine", and
|
|
14
|
+
# the `io` in that bundle is logging. A `File.read` in the same body is a different origin with a
|
|
15
|
+
# different bundle, and its `io.fs.read` still counts. That is why summaries keep labels per origin at
|
|
16
|
+
# all ({Summary#bundles}); a flat label set cannot tell the two `io`s apart.
|
|
17
|
+
#
|
|
18
|
+
# Discharge is a **judgment**, never a record. The snapshot on disk holds undischarged sets, the
|
|
19
|
+
# collector attributes undischarged labels, and only the two consumers below subtract:
|
|
20
|
+
# {EnvelopeCheck} (through {Propagator}'s second lane) and {SnapshotDiff}. `--no-tolerated-effects` is
|
|
21
|
+
# the audit switch — the same judgment with {NONE} — and is what makes the policy inspectable
|
|
22
|
+
# (Steins ADR-0084 invariant 3).
|
|
23
|
+
class Discharge
|
|
24
|
+
# The identity policy: nothing is tolerated, so nothing is discharged. What
|
|
25
|
+
# `--no-tolerated-effects` judges with, and what a project that configured no `tolerated:` list
|
|
26
|
+
# always has.
|
|
27
|
+
def self.none
|
|
28
|
+
@none ||= new([])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(tolerated)
|
|
32
|
+
@tolerated = tolerated.is_a?(LabelSet) ? tolerated : LabelSet.new(Array(tolerated).map(&:to_s))
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether the policy discharges nothing at all — the fast path every project that wrote no
|
|
37
|
+
# `tolerated:` list takes, and the reason the second propagation lane costs zero when unconfigured.
|
|
38
|
+
def inert?
|
|
39
|
+
@tolerated.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether ONE origin's bundle is discharged: some member of it is tolerated (or subsumed by a
|
|
43
|
+
# tolerated label — `tolerated: [io]` discharges an `io.fs.read` bundle). An empty bundle is
|
|
44
|
+
# discharged by nothing, and never carries anything to discharge.
|
|
45
|
+
def discharges?(labels)
|
|
46
|
+
return false if inert?
|
|
47
|
+
|
|
48
|
+
labels.to_a.any? { |label| @tolerated.admits?(label) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The join of every bundle this policy does NOT discharge — what a judgment reads in place of
|
|
52
|
+
# {Summary#proven}. A label that arrives through both a discharged and an undischarged origin
|
|
53
|
+
# survives, because the undischarged origin proves it on its own.
|
|
54
|
+
def undischarged(bundles)
|
|
55
|
+
return flatten(bundles) if inert?
|
|
56
|
+
|
|
57
|
+
bundles.reduce(LabelSet::EMPTY) do |acc, (_origin, labels)|
|
|
58
|
+
discharges?(labels) ? acc : acc.join(labels)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def flatten(bundles)
|
|
65
|
+
bundles.each_value.reduce(LabelSet::EMPTY) { |acc, set| acc.join(set) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label_set"
|
|
4
|
+
require_relative "summary"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Effects
|
|
8
|
+
# The whole-project effect graph after propagation: every method key the run collected, with its
|
|
9
|
+
# direct summary and its transitive closure (ADR-103 WD12).
|
|
10
|
+
#
|
|
11
|
+
# An EffectTable is **not a diagnostic and never enters `rigor check`'s stream**. It hangs off the
|
|
12
|
+
# runner for the report of this slice and the snapshot of #381 to read, exactly as
|
|
13
|
+
# [ADR-102](../adr/102-unused-code-reachability-report.md) draws the report-versus-diagnostic line.
|
|
14
|
+
class EffectTable
|
|
15
|
+
# One method's row.
|
|
16
|
+
#
|
|
17
|
+
# `direct` is the {Summary} the collector produced for this method's own body — what a snapshot
|
|
18
|
+
# records, because a diff over direct summaries stays attributable to the pull request's own lines.
|
|
19
|
+
# `proven` / `exhaustive` / `causes` are the transitive readings: this method's own labels joined
|
|
20
|
+
# with every project method it reaches, and the exhaustiveness bit ANDed along the same edges.
|
|
21
|
+
#
|
|
22
|
+
# `undischarged` is the same transitive reading with the origin bundles `effects.tolerated:`
|
|
23
|
+
# discharges removed at their source (#385; {Discharge}) — what a *judgment* reads, where `proven`
|
|
24
|
+
# is what the record holds. The two are the same set for a project that tolerates nothing, and
|
|
25
|
+
# `--no-tolerated-effects` is the switch that makes a judgment read `proven` anyway.
|
|
26
|
+
#
|
|
27
|
+
# `declared` is the transitive `≤` lane: it travels the same edges as `proven` (ADR-103 WD1), so a
|
|
28
|
+
# caller two hops above an attributed gem call reads the claim rather than only the taint it left.
|
|
29
|
+
# The lanes are kept **raw** here — a declared label a proven one already subsumes is dropped where
|
|
30
|
+
# output is rendered ({LabelSet#excluding_subsumed_by}), never in the table, because a further join
|
|
31
|
+
# has to see what was actually declared.
|
|
32
|
+
class Entry < Data.define(:key, :direct, :proven, :undischarged, :declared, :exhaustive, :causes,
|
|
33
|
+
:edges)
|
|
34
|
+
def initialize(undischarged: nil, declared: nil, **rest)
|
|
35
|
+
super(undischarged: undischarged || rest.fetch(:proven), declared: declared || LabelSet::EMPTY,
|
|
36
|
+
**rest)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The declared labels worth printing beside `proven` — the rendering rule, in one place so the
|
|
40
|
+
# report and the snapshot cannot disagree about it.
|
|
41
|
+
def rendered_declared
|
|
42
|
+
declared.excluding_subsumed_by(proven)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def exhaustive?
|
|
46
|
+
exhaustive
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether the report omits this row by default: exhaustive, proving nothing beyond frame-local
|
|
50
|
+
# mutation, and claiming nothing the proven lane does not already admit ({Summary#trivial?} for
|
|
51
|
+
# the reasoning behind the declared half). `--full` lists it anyway.
|
|
52
|
+
def trivial?
|
|
53
|
+
exhaustive && proven.subsumed_by?(Summary::TRIVIAL_BOUND) && rendered_declared.empty?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
EMPTY_ENTRIES = {}.freeze
|
|
58
|
+
private_constant :EMPTY_ENTRIES
|
|
59
|
+
|
|
60
|
+
def self.empty
|
|
61
|
+
@empty ||= new(EMPTY_ENTRIES)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param entries [Hash{String => Entry}]
|
|
65
|
+
def initialize(entries)
|
|
66
|
+
@entries = entries.sort_by { |key, _| key }.to_h.freeze
|
|
67
|
+
freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def [](key)
|
|
71
|
+
@entries[key]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def keys
|
|
75
|
+
@entries.keys
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def each(&)
|
|
79
|
+
@entries.each_value(&)
|
|
80
|
+
end
|
|
81
|
+
include Enumerable
|
|
82
|
+
|
|
83
|
+
def size
|
|
84
|
+
@entries.size
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def empty?
|
|
88
|
+
@entries.empty?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
|
|
6
|
+
#
|
|
7
|
+
# `reach:` entries are of two kinds, told apart syntactically:
|
|
8
|
+
#
|
|
9
|
+
# - a **path glob** — anything carrying a glob or path character (`* ? [ ] / .`) — matched against the
|
|
10
|
+
# project-relative file a method is defined in, with the `rigor unused --entry-point` semantics
|
|
11
|
+
# (`File.fnmatch?` with `File::FNM_PATHNAME`, so `**` is the only way across a directory boundary);
|
|
12
|
+
# - a **preset name** — a bare `[a-z0-9_-]+` token — resolved here to the globs the preset stands for.
|
|
13
|
+
#
|
|
14
|
+
# Presets are named by the plugin that models a framework — rigor-actionpack's controller actions,
|
|
15
|
+
# rigor-activejob's `perform`, rigor-actionmailer's mailer methods, rigor-actioncable's channels —
|
|
16
|
+
# through the `effect_entry_points:` manifest field (#387), and registered here through
|
|
17
|
+
# {.register_all} once the plugin set is loaded. `Configuration` checks only that a `reach:` entry is
|
|
18
|
+
# SHAPED like a preset name; the existence check runs where the snapshot expands it, which is the
|
|
19
|
+
# first point at which the registered set is complete.
|
|
20
|
+
#
|
|
21
|
+
# Registration is process-global and happens before a snapshot is built, in the parent process — the
|
|
22
|
+
# same shape the other CLI-time registries take. Nothing here crosses a fork or a Ractor boundary.
|
|
23
|
+
module EntryPoints
|
|
24
|
+
# What makes a `reach:` entry a path glob rather than a preset name. Kept deliberately wide: a name
|
|
25
|
+
# that looks at all like a path is read as one, because a mistyped preset that matched a file would
|
|
26
|
+
# be the confusing failure.
|
|
27
|
+
GLOB_CHARACTERS = %r{[*?\[\]/.]}
|
|
28
|
+
|
|
29
|
+
# What a preset name may be spelled with, so a malformed name is rejected at registration rather
|
|
30
|
+
# than becoming an unfindable key.
|
|
31
|
+
NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
32
|
+
|
|
33
|
+
class Error < StandardError
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@presets = {}
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Whether `entry` is a path glob rather than a preset name.
|
|
40
|
+
def glob?(entry)
|
|
41
|
+
GLOB_CHARACTERS.match?(entry)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Registers `name` as standing for `globs`. Re-registering the same name with the same globs is a
|
|
45
|
+
# no-op, so a plugin loaded twice in one process does not raise.
|
|
46
|
+
def register(name, globs)
|
|
47
|
+
key = name.to_s
|
|
48
|
+
raise Error, "not a well-formed entry-point preset name: #{key.inspect}" unless NAME_PATTERN.match?(key)
|
|
49
|
+
|
|
50
|
+
patterns = Array(globs).map(&:to_s).uniq.sort.freeze
|
|
51
|
+
existing = @presets[key]
|
|
52
|
+
return key if existing == patterns
|
|
53
|
+
raise Error, "entry-point preset already registered with different globs: #{key.inspect}" if existing
|
|
54
|
+
|
|
55
|
+
@presets[key] = patterns
|
|
56
|
+
key
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def known?(name)
|
|
60
|
+
@presets.key?(name.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether `entry` is spelled like a preset name at all — the shape check `Configuration` runs at
|
|
64
|
+
# load, before any plugin has had the chance to register one.
|
|
65
|
+
def name?(entry)
|
|
66
|
+
NAME_PATTERN.match?(entry)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registers every `effect_entry_points:` preset the loaded plugins declare. Idempotent per name +
|
|
70
|
+
# glob set, so two runs in one process (the spec suite, the LSP) do not collide; two plugins
|
|
71
|
+
# claiming one name with different globs is a genuine conflict and raises.
|
|
72
|
+
def register_all(presets)
|
|
73
|
+
Array(presets).each { |preset| register(preset.name, preset.globs) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Every registered preset name, sorted. Empty in this slice.
|
|
77
|
+
def names
|
|
78
|
+
@presets.keys.sort.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The globs `name` stands for, or `nil` when nothing registered it.
|
|
82
|
+
def globs_for(name)
|
|
83
|
+
@presets[name.to_s]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Drops every registration. For specs only — production code registers and never unregisters.
|
|
87
|
+
def reset!
|
|
88
|
+
@presets = {}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label_set"
|
|
4
|
+
require_relative "summary"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Effects
|
|
8
|
+
# An author-declared upper bound on one method's effect labels (ADR-103 WD1; normative in
|
|
9
|
+
# `docs/type-specification/effect-labels.md` § Effect envelopes).
|
|
10
|
+
#
|
|
11
|
+
# Four facts and one seam:
|
|
12
|
+
#
|
|
13
|
+
# - {#owner_key} — the method key the envelope binds (`Class#m` / `Class.m`). A class-level
|
|
14
|
+
# envelope is read once and {#rebind}ed onto each method of that class discovery knows.
|
|
15
|
+
# - {#bound} — the {LabelSet} the method's proven labels must be subsumed by. {LabelSet::TOP}
|
|
16
|
+
# means "no envelope": the fail-open reading a tag carrying an unknown label degrades to.
|
|
17
|
+
# - {#source} — which spelling produced it: `:pure_annotation` (`%a{pure}`),
|
|
18
|
+
# `:effect_annotation` (`%a{rigor:v1:effect …}` on the method), `:class_annotation` (either
|
|
19
|
+
# spelling on the class / module declaration, which is also what {#rebind} stamps — what matters
|
|
20
|
+
# downstream is that the bound was distributed rather than written on the method) or
|
|
21
|
+
# `:config_envelope` (an `effects.envelopes:` entry, {ConfigEnvelopes}). The last one survives
|
|
22
|
+
# {#rebind}: a configured envelope is distributed by construction, so "distributed" is not the
|
|
23
|
+
# fact worth stamping — where it was written is.
|
|
24
|
+
# - {#location} — `path:line` of the annotation, or `.rigor.yml effects.envelopes[N]` for a
|
|
25
|
+
# configured one, so the diagnostic can name where the bound was written; {#spelling} is the
|
|
26
|
+
# author's own text, quoted back verbatim.
|
|
27
|
+
# - {#unknown_labels} — the well-formed-but-unrecognised spellings that made this envelope read
|
|
28
|
+
# ⊤ ([#384](https://github.com/rigortype/rigor/issues/384)'s `effect.unknown-label` reads it).
|
|
29
|
+
# Empty otherwise.
|
|
30
|
+
# - {#declared_labels} — every token the tag listed, in source order, recognised or not (empty
|
|
31
|
+
# for `%a{pure}`, whose bound is written by its spelling rather than by a list). It is what
|
|
32
|
+
# answers "was some OTHER member of this list known?", one of the four signals
|
|
33
|
+
# {LabelIntent} reads intent off.
|
|
34
|
+
#
|
|
35
|
+
# `mutate.local` is tolerated by **every** envelope, `%a{pure}` included: a method may freely
|
|
36
|
+
# mutate what its own frame allocated and never let escape.
|
|
37
|
+
class Envelope < Data.define(:owner_key, :bound, :source, :location, :spelling, :unknown_labels,
|
|
38
|
+
:declared_labels)
|
|
39
|
+
NO_LABELS = [].freeze
|
|
40
|
+
private_constant :NO_LABELS
|
|
41
|
+
|
|
42
|
+
# {#source} for a bound written in `.rigor.yml` rather than on a declaration.
|
|
43
|
+
CONFIG_SOURCE = :config_envelope
|
|
44
|
+
|
|
45
|
+
def self.build(owner_key:, bound:, source:, location: nil, spelling: nil, unknown_labels: NO_LABELS,
|
|
46
|
+
declared_labels: NO_LABELS)
|
|
47
|
+
new(
|
|
48
|
+
owner_key: owner_key, bound: bound, source: source, location: location,
|
|
49
|
+
spelling: spelling, unknown_labels: unknown_labels.uniq.sort.freeze,
|
|
50
|
+
declared_labels: declared_labels.dup.freeze
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether this reads as ⊤ — no bound at all. An unknown label degrades the whole tag here, so a
|
|
55
|
+
# typo suppresses findings rather than inventing them (the fail-open rule).
|
|
56
|
+
def top?
|
|
57
|
+
bound.top?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether `label` is inside the bound. `mutate.local` always is.
|
|
61
|
+
def tolerates?(label)
|
|
62
|
+
bound.admits?(label) || Summary::TRIVIAL_BOUND.admits?(label)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The members of `label_set` this envelope does NOT admit, sorted. One diagnostic per member.
|
|
66
|
+
def exceeded_by(label_set)
|
|
67
|
+
return NO_LABELS if top?
|
|
68
|
+
|
|
69
|
+
label_set.to_a.reject { |label| tolerates?(label) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the bound was written in `.rigor.yml` rather than on a declaration.
|
|
73
|
+
def config?
|
|
74
|
+
source == CONFIG_SOURCE
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The same bound, attached to another method key — how a class-level envelope reaches each
|
|
78
|
+
# method of its class. The source becomes `:class_annotation`, because the distribution is the
|
|
79
|
+
# fact the diagnostic has to explain; a configured envelope keeps its own source, because for it
|
|
80
|
+
# distribution is the only mode there is and `.rigor.yml` is the fact worth naming.
|
|
81
|
+
def rebind(key)
|
|
82
|
+
config? ? with(owner_key: key) : with(owner_key: key, source: :class_annotation)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "envelope"
|
|
4
|
+
require_relative "effect_table"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
require_relative "path_finder"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# Judges each method's declared envelope against what the run actually proved (ADR-103 WD1 / WD8;
|
|
11
|
+
# #383). The one place `effect.envelope-exceeded` is decided.
|
|
12
|
+
#
|
|
13
|
+
# Three rules make this FP-safe, and each is load-bearing:
|
|
14
|
+
#
|
|
15
|
+
# - **It reads the PROVEN lane only**, never the declared one and never taint. A non-exhaustive
|
|
16
|
+
# summary reads "these effects, and possibly more" and contributes no finding of its own; what
|
|
17
|
+
# it *did* prove is still proven, so a proven label outside the bound still fires. That is "as
|
|
18
|
+
# strict as proven" ([robustness-principle.md](../../../docs/type-specification/robustness-principle.md)),
|
|
19
|
+
# and it is why an unresolved call can never manufacture one.
|
|
20
|
+
# - **The bound binds the method's CODE, transitively.** The envelope is a contract about what the
|
|
21
|
+
# method does, and a repository that calls a helper that calls `Net::HTTP` does perform HTTP. So
|
|
22
|
+
# the comparison is against {EffectTable::Entry#proven} — the fixpoint's closure over project
|
|
23
|
+
# callees — not against the unit's own direct summary.
|
|
24
|
+
# - **`mutate.local` is tolerated by every envelope**, `%a{pure}` included ({Envelope#tolerates?}).
|
|
25
|
+
#
|
|
26
|
+
# **`effects.tolerated:` discharges, per origin** (#385). The comparison reads
|
|
27
|
+
# {EffectTable::Entry#undischarged} rather than `proven`: the propagator has already dropped every
|
|
28
|
+
# origin bundle the policy discharges and closed the rest over the graph, so a `pure`-declared method
|
|
29
|
+
# that logs is silent under `tolerated: [telemetry]` while the `File.read` two lines down still fires.
|
|
30
|
+
# `apply_tolerated: false` — `--no-tolerated-effects` — judges against `proven` instead, which is the
|
|
31
|
+
# audit switch that makes the policy inspectable rather than invisible.
|
|
32
|
+
module EnvelopeCheck
|
|
33
|
+
# One (method, exceeding label) pair. `chain` is the shortest project-method path from the
|
|
34
|
+
# method to whatever proves the label and `origin` is the callee key or construct at its end —
|
|
35
|
+
# the same explanation `rigor effects explain` prints, so the diagnostic tells a reader where to
|
|
36
|
+
# look rather than only that something is wrong.
|
|
37
|
+
Finding = Data.define(:key, :label, :envelope, :path, :line, :chain, :origin)
|
|
38
|
+
|
|
39
|
+
# The discovery tables a finding's POSITION is read from, as one value — `discovered_def_sources`
|
|
40
|
+
# (`{class => {method_sym => "path:line"}}`), its `def self.x` mirror, and `discovered_class_sources`
|
|
41
|
+
# (`{class => Set[path]}`), the fallback for a method with no Ruby `def` at all. Kept together so the
|
|
42
|
+
# check's own signature stays about the judgment rather than about where a `def` lives.
|
|
43
|
+
class Positions < Data.define(:def_sources, :singleton_def_sources, :class_sources)
|
|
44
|
+
NONE = {}.freeze
|
|
45
|
+
private_constant :NONE
|
|
46
|
+
|
|
47
|
+
def self.empty
|
|
48
|
+
@empty ||= build
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.build(def_sources: nil, singleton_def_sources: nil, class_sources: nil)
|
|
52
|
+
new(def_sources: def_sources || NONE, singleton_def_sources: singleton_def_sources || NONE,
|
|
53
|
+
class_sources: class_sources || NONE)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Where a finding about `key` goes: the Ruby `def`, from the discovery tables (ADR-103 WD14 chose
|
|
57
|
+
# the `def` over the `.rbs` line deliberately — `# rigor:disable` reads only Ruby comments). A
|
|
58
|
+
# method with no `def` at all is a synthesized accessor, and the class's own source is the closest
|
|
59
|
+
# thing to a position it has.
|
|
60
|
+
#
|
|
61
|
+
# It lives on the value rather than in {EnvelopeCheck} because {LiskovCheck} positions its
|
|
62
|
+
# findings identically, and two spellings of "where the fix goes" would eventually disagree.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array(String, Integer)] `[path, line]`; `[nil, 1]` for a key with no owner.
|
|
65
|
+
def for(key)
|
|
66
|
+
class_name, separator, selector = MethodKey.split(key)
|
|
67
|
+
return [nil, 1] if class_name.nil?
|
|
68
|
+
|
|
69
|
+
table = separator == "." ? singleton_def_sources : def_sources
|
|
70
|
+
site = table.dig(class_name, selector.to_sym)
|
|
71
|
+
return split_site(site) if site
|
|
72
|
+
|
|
73
|
+
[Array(class_sources[class_name]).first, 1]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def split_site(site)
|
|
79
|
+
path, _, line = site.to_s.rpartition(":")
|
|
80
|
+
return [site.to_s, 1] if path.empty?
|
|
81
|
+
|
|
82
|
+
[path, line.to_i.positive? ? line.to_i : 1]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
NO_FINDINGS = [].freeze
|
|
87
|
+
private_constant :NO_FINDINGS
|
|
88
|
+
|
|
89
|
+
module_function
|
|
90
|
+
|
|
91
|
+
# @param table [EffectTable] the run's propagated graph.
|
|
92
|
+
# @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
|
|
93
|
+
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
94
|
+
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
95
|
+
# 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` position is read from.
|
|
97
|
+
# @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
|
|
98
|
+
# the `--no-tolerated-effects` audit switch.
|
|
99
|
+
# @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
|
|
100
|
+
def run(table:, method_envelopes:, class_envelopes:, config_envelopes: {},
|
|
101
|
+
positions: Positions.empty, apply_tolerated: true)
|
|
102
|
+
envelopes = distribute(table, method_envelopes, class_envelopes, config_envelopes)
|
|
103
|
+
return NO_FINDINGS if envelopes.empty?
|
|
104
|
+
|
|
105
|
+
findings = []
|
|
106
|
+
envelopes.each do |key, envelope|
|
|
107
|
+
collect(findings, table, key, envelope, positions, apply_tolerated)
|
|
108
|
+
end
|
|
109
|
+
findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resolves the per-method envelope for every unit the table knows, **nearest wins**:
|
|
113
|
+
#
|
|
114
|
+
# per-method annotation > class-level annotation > `effects.envelopes:` entry
|
|
115
|
+
#
|
|
116
|
+
# The two class-shaped strata distribute identically — an envelope keyed by a class name reaches
|
|
117
|
+
# every method key of THAT Ruby class, so a subclass's keys never match and a module distributes to
|
|
118
|
+
# its own methods only — and are applied in that order, so a written annotation always wins over a
|
|
119
|
+
# convention. Which config entry a class matched was already decided by {ConfigEnvelopes.for_classes}.
|
|
120
|
+
#
|
|
121
|
+
# Public because {LiskovCheck} resolves the *ancestor's* envelope by exactly these rules: an
|
|
122
|
+
# inherited bound has to be the same bound the ancestor is itself held to, or the two checks would
|
|
123
|
+
# disagree about what the author wrote.
|
|
124
|
+
def distribute(table, method_envelopes, class_envelopes, config_envelopes)
|
|
125
|
+
resolved = {}
|
|
126
|
+
unless class_envelopes.empty? && config_envelopes.empty?
|
|
127
|
+
keys_by_class(table).each do |class_name, keys|
|
|
128
|
+
envelope = class_envelopes[class_name] || config_envelopes[class_name]
|
|
129
|
+
next if envelope.nil?
|
|
130
|
+
|
|
131
|
+
keys.each { |key| resolved[key] = envelope.rebind(key) }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
method_envelopes.each { |key, envelope| resolved[key] = envelope if table[key] }
|
|
135
|
+
resolved
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# `{class name => [method key]}` over the units the run collected — the "every method discovery
|
|
139
|
+
# knows" of the class-level distribution rule, including reopenings in other files and the
|
|
140
|
+
# synthesized `attr_*` / `define_method` members the effects scanner adds.
|
|
141
|
+
def keys_by_class(table)
|
|
142
|
+
table.keys.each_with_object({}) do |key, out|
|
|
143
|
+
owner = MethodKey.owner(key)
|
|
144
|
+
next if owner.nil?
|
|
145
|
+
|
|
146
|
+
(out[owner] ||= []) << key
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def collect(findings, table, key, envelope, positions, apply_tolerated)
|
|
151
|
+
return if envelope.top?
|
|
152
|
+
|
|
153
|
+
entry = table[key]
|
|
154
|
+
return if entry.nil?
|
|
155
|
+
|
|
156
|
+
exceeding = envelope.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
157
|
+
return if exceeding.empty?
|
|
158
|
+
|
|
159
|
+
path, line = positions.for(key)
|
|
160
|
+
exceeding.each do |label|
|
|
161
|
+
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
162
|
+
findings << Finding.new(
|
|
163
|
+
key: key, label: label, envelope: envelope, path: path, line: line,
|
|
164
|
+
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private_class_method :keys_by_class, :collect
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_envelopes"
|
|
4
|
+
require_relative "envelope"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
require_relative "signature_sources"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Effects
|
|
11
|
+
# The envelopes a **call site** may import as a `≤` bound, looked up by the callee the typer named
|
|
12
|
+
# (ADR-103 WD6; #386; normative in `docs/type-specification/effect-labels.md` § The declared lane at
|
|
13
|
+
# call sites).
|
|
14
|
+
#
|
|
15
|
+
# {EnvelopeCheck} asks "what bounds THIS method's body?" and answers it from the project's own
|
|
16
|
+
# declarations alone, because that is the stratum a contract can be checked against. This index asks
|
|
17
|
+
# the other question — "what does the thing I am calling promise?" — and so reads one stratum more:
|
|
18
|
+
# an **accepted signature**'s annotation, from the built RBS environment. WD6's trust ladder puts
|
|
19
|
+
# both on the discharging side, and the two questions are kept in two objects because only the first
|
|
20
|
+
# one may ever produce a finding.
|
|
21
|
+
#
|
|
22
|
+
# Four sources, nearest-first, exactly the check's precedence with the accepted stratum appended:
|
|
23
|
+
#
|
|
24
|
+
# per-method annotation > class-level annotation > `effects.envelopes:` > accepted signature
|
|
25
|
+
#
|
|
26
|
+
# The carrier is **nominal**: the lookup is by the receiver's *static* class name as the typer
|
|
27
|
+
# projected it (or, for an implicit-self call, by the unit's own class), never by walking ancestors.
|
|
28
|
+
# A structural interface erases to `Dynamic[top]` today and has nothing to attach a bound to
|
|
29
|
+
# ([ADR-103](../../../docs/adr/103-effect-labels.md) WD6); an inherited envelope reaches an override
|
|
30
|
+
# through `effect.liskov-widened` instead, which is a judgment rather than an import.
|
|
31
|
+
#
|
|
32
|
+
# Two bounds of the slice, both deliberate:
|
|
33
|
+
#
|
|
34
|
+
# - An `effects.envelopes:` entry selected by **`match:`** does not participate here. A path glob is
|
|
35
|
+
# a fact about where a class is *defined*, which a per-file collection window cannot see without
|
|
36
|
+
# the whole-project class-source table; `namespace:` needs only the name and does participate. The
|
|
37
|
+
# entry still bounds its own classes' methods for {EnvelopeCheck} and the Liskov check.
|
|
38
|
+
# - Lookup is by the *exact* owner. `repo.find` on a receiver typed `PgRepo` does not import
|
|
39
|
+
# `Repo#find`'s bound.
|
|
40
|
+
#
|
|
41
|
+
# Values are Marshal-clean ({Envelope} over frozen Strings and {LabelSet}s), because the index is
|
|
42
|
+
# built per process — the parent and each fork-pool worker build their own from the same
|
|
43
|
+
# configuration and the same signature content, so the two agree without a channel to keep in sync.
|
|
44
|
+
class EnvelopeIndex
|
|
45
|
+
NO_ENVELOPES = {}.freeze
|
|
46
|
+
private_constant :NO_ENVELOPES
|
|
47
|
+
|
|
48
|
+
NO_ENTRIES = [].freeze
|
|
49
|
+
private_constant :NO_ENTRIES
|
|
50
|
+
|
|
51
|
+
# The index a run with no declaration of any kind uses — and the fail-soft answer for a build that
|
|
52
|
+
# raised. Every lookup on it is one `empty?` read.
|
|
53
|
+
def self.empty
|
|
54
|
+
@empty ||= new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reads every stratum this index serves, once per process.
|
|
58
|
+
#
|
|
59
|
+
# @param configuration [Rigor::Configuration]
|
|
60
|
+
# @param plugin_facts [Rigor::Effects::PluginFacts, nil] the loaded plugins' effect contributions
|
|
61
|
+
# (#387); their `effect_labels:` join the vocabulary an annotation is read against, so a gem's
|
|
62
|
+
# `%a{rigor:v1:effect rails.activejob.enqueue}` resolves rather than reading as unknown.
|
|
63
|
+
# @param environment [Rigor::Environment, nil] the run's environment. Its loader supplies the
|
|
64
|
+
# rbs-inline / plugin `virtual_rbs` buffers and the built RBS environment the accepted stratum is
|
|
65
|
+
# read from; without one, both are simply absent (the fail-quiet direction — a missing `≤` bound
|
|
66
|
+
# costs precision, never a finding).
|
|
67
|
+
# @return [EnvelopeIndex]
|
|
68
|
+
def self.build(configuration:, environment: nil, plugin_facts: nil)
|
|
69
|
+
# Required here rather than at the top of the file: the reader pulls in the whole
|
|
70
|
+
# `RbsExtended` surface, and this build runs only under an `effects:` block.
|
|
71
|
+
require_relative "../rbs_extended/envelope_scanner"
|
|
72
|
+
|
|
73
|
+
registry = Registry.for_configuration(configuration, plugin_facts: plugin_facts)
|
|
74
|
+
loader = environment&.rbs_loader
|
|
75
|
+
scan = RbsExtended::EnvelopeScanner.scan(
|
|
76
|
+
sources: SignatureSources.collect(
|
|
77
|
+
signature_paths: configuration.signature_paths, virtual_rbs: loader&.virtual_rbs
|
|
78
|
+
),
|
|
79
|
+
registry: registry
|
|
80
|
+
)
|
|
81
|
+
new(
|
|
82
|
+
method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
|
|
83
|
+
config_entries: ConfigEnvelopes.build(entries: configuration.effects_envelopes, registry: registry),
|
|
84
|
+
accepted: accepted_for(loader, registry)
|
|
85
|
+
)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
empty
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The accepted stratum — every `%a{pure}` / `%a{rigor:v1:effect …}` on a method definition in the
|
|
91
|
+
# **built** RBS environment: a gem's shipped signatures, Rigor's bundled overlays, core RBS.
|
|
92
|
+
#
|
|
93
|
+
# It is read-only by construction. Nothing here can bound a project method's body, because the
|
|
94
|
+
# contract check ({EnvelopeCheck}) reads {RbsExtended::EnvelopeScanner}'s project-source tables and
|
|
95
|
+
# never this one; what an accepted signature can do is state what a call into it promises. The
|
|
96
|
+
# project's own signatures are in the built environment too and therefore appear here as well —
|
|
97
|
+
# harmlessly, since the project strata are consulted first and both discharge.
|
|
98
|
+
def self.accepted_for(loader, registry)
|
|
99
|
+
return NO_ENVELOPES if loader.nil?
|
|
100
|
+
|
|
101
|
+
RbsExtended::EnvelopeScanner.from_loader(loader: loader, registry: registry)
|
|
102
|
+
rescue StandardError
|
|
103
|
+
NO_ENVELOPES
|
|
104
|
+
end
|
|
105
|
+
private_class_method :accepted_for
|
|
106
|
+
|
|
107
|
+
def initialize(method_envelopes: NO_ENVELOPES, class_envelopes: NO_ENVELOPES,
|
|
108
|
+
config_entries: NO_ENTRIES, accepted: NO_ENVELOPES)
|
|
109
|
+
@method_envelopes = method_envelopes.freeze
|
|
110
|
+
@class_envelopes = class_envelopes.freeze
|
|
111
|
+
@config_entries = config_entries.reject { |entry| entry.namespace.nil? }.freeze
|
|
112
|
+
@accepted = accepted.freeze
|
|
113
|
+
@config_cache = {}
|
|
114
|
+
freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether no stratum has anything to say. The scan's fast path: a project with no envelope of any
|
|
118
|
+
# kind pays one predicate per call site and nothing else.
|
|
119
|
+
def empty?
|
|
120
|
+
@method_envelopes.empty? && @class_envelopes.empty? && @config_entries.empty? && @accepted.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The envelope bounding `owner`'s `selector`, or nil.
|
|
124
|
+
#
|
|
125
|
+
# @param owner [String] the receiver's static class name, as the typer projected it
|
|
126
|
+
# @param singleton [Boolean] whether the call is `Owner.selector` rather than `Owner#selector`
|
|
127
|
+
# @param selector [String]
|
|
128
|
+
# @return [Envelope, nil] never a ⊤ envelope: a bound that bounds nothing is not a bound, and
|
|
129
|
+
# importing it would both add nothing and discharge a taint on the strength of a typo.
|
|
130
|
+
def [](owner, singleton, selector)
|
|
131
|
+
return nil if owner.nil? || empty?
|
|
132
|
+
|
|
133
|
+
key = "#{owner}#{singleton ? '.' : '#'}#{selector}"
|
|
134
|
+
envelope = @method_envelopes[key] || @class_envelopes[owner] || config_envelope(owner) ||
|
|
135
|
+
@accepted[key]
|
|
136
|
+
return nil if envelope.nil? || envelope.top?
|
|
137
|
+
|
|
138
|
+
envelope
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
# The first `namespace:` entry selecting `owner`, memoised per class name — one call site's owner
|
|
144
|
+
# is asked for once per site and a project has a handful of entries.
|
|
145
|
+
def config_envelope(owner)
|
|
146
|
+
return nil if @config_entries.empty?
|
|
147
|
+
|
|
148
|
+
if @config_cache.key?(owner)
|
|
149
|
+
@config_cache[owner]
|
|
150
|
+
else
|
|
151
|
+
entry = @config_entries.find { |candidate| ConfigEnvelopes.namespace_match?(candidate.namespace, owner) }
|
|
152
|
+
@config_cache[owner] = entry && ConfigEnvelopes.envelope_for(entry, owner)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|