rigortype 0.3.3 → 0.3.5
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 +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -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/rule_ids.rb +28 -2
- 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 +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -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 +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -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 +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -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 +139 -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 +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -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 +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -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 +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -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/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -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 +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- 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 +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- 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-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/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../configuration_error"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
|
|
8
|
+
#
|
|
9
|
+
# `reach:` entries are of two kinds, told apart syntactically:
|
|
10
|
+
#
|
|
11
|
+
# - a **path glob** — anything carrying a glob or path character (`* ? [ ] / .`) — matched against the
|
|
12
|
+
# project-relative file a method is defined in, with the `rigor unused --entry-point` semantics
|
|
13
|
+
# (`File.fnmatch?` with `File::FNM_PATHNAME`, so `**` is the only way across a directory boundary);
|
|
14
|
+
# - a **preset name** — a bare `[a-z0-9_-]+` token — resolved here to the globs the preset stands for.
|
|
15
|
+
#
|
|
16
|
+
# Presets are named by the plugin that models a framework — rigor-actionpack's controller actions,
|
|
17
|
+
# rigor-activejob's `perform`, rigor-actionmailer's mailer methods, rigor-actioncable's channels —
|
|
18
|
+
# through the `effect_entry_points:` manifest field (#387), and registered here through
|
|
19
|
+
# {.register_all} once the plugin set is loaded. `Configuration` checks only that a `reach:` entry is
|
|
20
|
+
# SHAPED like a preset name; the existence check runs where the snapshot expands it, which is the
|
|
21
|
+
# first point at which the registered set is complete.
|
|
22
|
+
#
|
|
23
|
+
# Registration is process-global and happens before a snapshot is built, in the parent process — the
|
|
24
|
+
# same shape the other CLI-time registries take. Nothing here crosses a fork or a Ractor boundary.
|
|
25
|
+
module EntryPoints
|
|
26
|
+
# What makes a `reach:` entry a path glob rather than a preset name. Kept deliberately wide: a name
|
|
27
|
+
# that looks at all like a path is read as one, because a mistyped preset that matched a file would
|
|
28
|
+
# be the confusing failure.
|
|
29
|
+
GLOB_CHARACTERS = %r{[*?\[\]/.]}
|
|
30
|
+
|
|
31
|
+
# What a preset name may be spelled with, so a malformed name is rejected at registration rather
|
|
32
|
+
# than becoming an unfindable key.
|
|
33
|
+
NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
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.
|
|
38
|
+
class Error < StandardError
|
|
39
|
+
end
|
|
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
|
+
|
|
48
|
+
@presets = {}
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Whether `entry` is a path glob rather than a preset name.
|
|
52
|
+
def glob?(entry)
|
|
53
|
+
GLOB_CHARACTERS.match?(entry)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Registers `name` as standing for `globs`. Re-registering the same name with the same globs is a
|
|
57
|
+
# no-op, so a plugin loaded twice in one process does not raise.
|
|
58
|
+
def register(name, globs)
|
|
59
|
+
key = name.to_s
|
|
60
|
+
raise Error, "not a well-formed entry-point preset name: #{key.inspect}" unless NAME_PATTERN.match?(key)
|
|
61
|
+
|
|
62
|
+
patterns = Array(globs).map(&:to_s).uniq.sort.freeze
|
|
63
|
+
existing = @presets[key]
|
|
64
|
+
return key if existing == patterns
|
|
65
|
+
raise Error, "entry-point preset already registered with different globs: #{key.inspect}" if existing
|
|
66
|
+
|
|
67
|
+
@presets[key] = patterns
|
|
68
|
+
key
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def known?(name)
|
|
72
|
+
@presets.key?(name.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Whether `entry` is spelled like a preset name at all — the shape check `Configuration` runs at
|
|
76
|
+
# load, before any plugin has had the chance to register one.
|
|
77
|
+
def name?(entry)
|
|
78
|
+
NAME_PATTERN.match?(entry)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Registers every `effect_entry_points:` preset the loaded plugins declare. Idempotent per name +
|
|
82
|
+
# glob set, so two runs in one process (the spec suite, the LSP) do not collide; two plugins
|
|
83
|
+
# claiming one name with different globs is a genuine conflict and raises.
|
|
84
|
+
def register_all(presets)
|
|
85
|
+
Array(presets).each { |preset| register(preset.name, preset.globs) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every registered preset name, sorted. Empty until a plugin declaring `effect_entry_points:`
|
|
89
|
+
# loads, which is what makes {availability} worth printing.
|
|
90
|
+
def names
|
|
91
|
+
@presets.keys.sort.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The globs `name` stands for, or `nil` when nothing registered it.
|
|
95
|
+
def globs_for(name)
|
|
96
|
+
@presets[name.to_s]
|
|
97
|
+
end
|
|
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
|
+
|
|
132
|
+
# Drops every registration. For specs only — production code registers and never unregisters.
|
|
133
|
+
def reset!
|
|
134
|
+
@presets = {}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
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
|