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
|
@@ -4,8 +4,10 @@ require_relative "result"
|
|
|
4
4
|
require_relative "run_cache_key"
|
|
5
5
|
require_relative "path_expansion"
|
|
6
6
|
require_relative "severity_stamp"
|
|
7
|
+
require_relative "runner/effect_annotation_residual_pass"
|
|
7
8
|
require_relative "../cache/store"
|
|
8
9
|
require_relative "../cache/file_digest"
|
|
10
|
+
require_relative "../effects/signature_sources"
|
|
9
11
|
|
|
10
12
|
module Rigor
|
|
11
13
|
module Analysis
|
|
@@ -23,6 +25,31 @@ module Rigor
|
|
|
23
25
|
# sound because their inputs are in that same validated set. The key is built through the shared
|
|
24
26
|
# {RunCacheKey} — a project whose plugins synthesise virtual RBS produces a probe key that omits that
|
|
25
27
|
# entry, so it simply misses and the full path takes over (never a wrong hit).
|
|
28
|
+
#
|
|
29
|
+
# ## What the slot does NOT contain (#428)
|
|
30
|
+
#
|
|
31
|
+
# ADR-103 puts the two effect diagnostics OUTSIDE the cached run assembly on purpose: the `effects:`
|
|
32
|
+
# block is deliberately absent from the diagnostics cache identity, so a finding written into that
|
|
33
|
+
# entry would outlive the configuration that produced it. `Runner#run_analysis` therefore appends them
|
|
34
|
+
# after `#compute_run_diagnostics` — which is exactly the code a served hit skips. Serving the slot
|
|
35
|
+
# verbatim silently dropped both of them on every warm run, and a check that only ever fires on a cold
|
|
36
|
+
# cache is worse than one that never fires at all.
|
|
37
|
+
#
|
|
38
|
+
# A probe that serves a slot has to answer for what the slot omits, so this one does, per pass:
|
|
39
|
+
#
|
|
40
|
+
# - `effect.annotations-unchecked` is **reproduced here**. It was built to be free (a glob and a regex
|
|
41
|
+
# over the project's own signature tree — {Effects::SignatureSources}), so the probe simply runs it,
|
|
42
|
+
# with no virtual RBS: the inline stratum is the documented fail-quiet direction of that pass, and it
|
|
43
|
+
# is the stratum a run without an environment never had. #441 — that omission is unreachable rather
|
|
44
|
+
# than merely tolerated, because a project whose plugins synthesise virtual RBS is exactly the one
|
|
45
|
+
# whose probe key omits the `rbs.virtual_rbs` entry (§ above), so it misses here and the full path —
|
|
46
|
+
# which carries the stratum in every run mode — answers instead.
|
|
47
|
+
# - `effect.envelope-exceeded` / `effect.liskov-widened` / `effect.unknown-label` cannot be: they read
|
|
48
|
+
# the propagated effect graph and the cross-file discovery tables, i.e. the engine this path exists
|
|
49
|
+
# to skip. So the probe **declines** for a project that could earn one ({#envelope_lane_live?}) and
|
|
50
|
+
# the full path — which caches both halves in its own two slots, and re-judges them every run —
|
|
51
|
+
# serves it instead. The decline is measured against the declarations alone, never against what they
|
|
52
|
+
# would judge to, so it costs one glob and never a wrong answer.
|
|
26
53
|
class RunCacheProbe
|
|
27
54
|
# @param configuration [Rigor::Configuration]
|
|
28
55
|
# @param cache_root [String]
|
|
@@ -48,14 +75,55 @@ module Rigor
|
|
|
48
75
|
|
|
49
76
|
diagnostics = validated_diagnostics(key)
|
|
50
77
|
return nil if diagnostics.nil?
|
|
78
|
+
# #428 — asked only after the peek, so a run that was going to miss anyway never pays the walk.
|
|
79
|
+
return nil if envelope_lane_live?
|
|
51
80
|
|
|
52
|
-
Result.new(
|
|
81
|
+
Result.new(
|
|
82
|
+
diagnostics: SeverityStamp.apply(diagnostics + residual_diagnostics, @configuration), stats: nil
|
|
83
|
+
)
|
|
53
84
|
rescue StandardError
|
|
54
85
|
nil
|
|
55
86
|
end
|
|
56
87
|
|
|
57
88
|
private
|
|
58
89
|
|
|
90
|
+
# #428 — whether this project could earn one of the three diagnostics {Runner::EffectEnvelopePass}
|
|
91
|
+
# produces, which are the ones no cached slot carries and no engine-free path can recompute.
|
|
92
|
+
#
|
|
93
|
+
# Read off the DECLARATIONS, because a declaration is the whole of what the engine-free side can
|
|
94
|
+
# see: the four `.rigor.yml` policy lists, and whether the project's own signature tree carries an
|
|
95
|
+
# effect annotation at all ({Effects::SignatureSources::ANNOTATION_HINT}, the same one-regex-per-file
|
|
96
|
+
# pre-filter the envelope reader routes on). Over-declining is free — it forgoes a fast lane for a
|
|
97
|
+
# run the full path still serves out of the same two warm slots — while under-declining is the bug
|
|
98
|
+
# this method exists for, so anything ambiguous answers true.
|
|
99
|
+
def envelope_lane_live?
|
|
100
|
+
return false unless @configuration.effects_check?
|
|
101
|
+
|
|
102
|
+
declared_in_config? || !Effects::SignatureSources.first_annotated(signature_sources).nil?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The `effects:` policy surface an `effect.unknown-label` can be read off, plus the envelopes an
|
|
106
|
+
# `effect.envelope-exceeded` / `effect.liskov-widened` is judged against. A project that opted into
|
|
107
|
+
# effects and declared none of them has nothing for the pass to say.
|
|
108
|
+
def declared_in_config?
|
|
109
|
+
!@configuration.effects_envelopes.empty? || !@configuration.effects_tolerated.empty? ||
|
|
110
|
+
!@configuration.effects_labels.empty? || !@configuration.effects_attribution.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# `effect.annotations-unchecked`, reproduced verbatim — the pass self-gates on `effects_enabled?`,
|
|
114
|
+
# so exactly one of it and {#envelope_lane_live?} is ever non-empty, as on the full path.
|
|
115
|
+
def residual_diagnostics
|
|
116
|
+
Runner::EffectAnnotationResidualPass.new(configuration: @configuration).diagnostics
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# One walk, whichever of the two lanes asks: the residual pass collects its own (it is the one that
|
|
120
|
+
# runs when effects are off), so the memo only ever serves the envelope-lane decline.
|
|
121
|
+
def signature_sources
|
|
122
|
+
@signature_sources ||= Effects::SignatureSources.collect(
|
|
123
|
+
signature_paths: @configuration.signature_paths
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
59
127
|
def validated_diagnostics(key)
|
|
60
128
|
store = Cache::Store.new(root: @cache_root, max_bytes: @configuration.cache_max_bytes)
|
|
61
129
|
# The digest fallback + `cache.validation` / RIGOR_STRICT_VALIDATION escape hatch route through the
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
class Runner
|
|
6
|
+
# Where an `effect.unknown-label` finding is positioned (ADR-103 WD14; #384).
|
|
7
|
+
#
|
|
8
|
+
# The declaration, not the method: this is a fact about what the author wrote, and the fix is on
|
|
9
|
+
# that line. A `.rbs` position is the right answer here even though its sibling deliberately avoids
|
|
10
|
+
# one (the `rbs_extended.unsatisfied-conformance` precedent) — there is no Ruby `def` that could
|
|
11
|
+
# carry the typo. A value written in configuration has no location at all and lands at
|
|
12
|
+
# `.rigor.yml:1`, the `rbs.coverage.quarantined-signature` precedent.
|
|
13
|
+
# A declaration's `location` is already the position a reader can open, `.rbs` and rbs-inline
|
|
14
|
+
# alike: the synthesized-buffer line is re-anchored onto the Ruby file where the envelope is
|
|
15
|
+
# built ({Rigor::Effects::InlineAnchor}, #432), so this module only has to split it. Doing the
|
|
16
|
+
# re-anchoring here as well used to be the fix, and it could not tell two identically-spelled
|
|
17
|
+
# annotations in one file apart — every finding in it landed on the first.
|
|
18
|
+
module DeclarationPosition
|
|
19
|
+
CONFIG_PATH = ".rigor.yml"
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param finding [#location]
|
|
24
|
+
# @return [Array(String, Integer)] `[path, line]`
|
|
25
|
+
def of(finding)
|
|
26
|
+
location = finding.location
|
|
27
|
+
return [CONFIG_PATH, 1] if location.nil?
|
|
28
|
+
|
|
29
|
+
path, _, raw_line = location.rpartition(":")
|
|
30
|
+
return [CONFIG_PATH, 1] if path.empty?
|
|
31
|
+
|
|
32
|
+
line = raw_line.to_i
|
|
33
|
+
[path, line.positive? ? line : 1]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostic"
|
|
4
|
+
# ADR-87 WD4 — the pure-data rule-id table, never the engine-heavy `check_rules.rb` that reopens the
|
|
5
|
+
# same module. This pass is one of the two the boot-slimming {Analysis::RunCacheProbe} has to run for
|
|
6
|
+
# itself on a cache hit (#428), and requiring the full rule set would put `rigor/inference` back into
|
|
7
|
+
# a hit's `$LOADED_FEATURES`.
|
|
8
|
+
require_relative "../check_rules/rule_ids"
|
|
9
|
+
require_relative "../../effects/inline_anchor"
|
|
10
|
+
require_relative "../../effects/signature_sources"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
module Analysis
|
|
14
|
+
class Runner
|
|
15
|
+
# `effect.annotations-unchecked` — the ADR-103 WD13 commitment-1 residual (#384).
|
|
16
|
+
#
|
|
17
|
+
# WD13 fixes two rules that pull against each other: an annotation **must not** turn effect
|
|
18
|
+
# collection on (a `%a{pure}` written for Steep's benefit would otherwise impose a project-wide
|
|
19
|
+
# cost cliff on every run of every project that has one), and an annotation **must not** be
|
|
20
|
+
# silently inert either. One `:info` per run is the whole reconciliation: the declarations are
|
|
21
|
+
# there, nothing reads them, and `effects: {}` is the one-line answer.
|
|
22
|
+
#
|
|
23
|
+
# ## What it is allowed to cost
|
|
24
|
+
#
|
|
25
|
+
# This runs on the surface that is meant to be free — a project with no `effects:` block, which
|
|
26
|
+
# is every project by default — so the budget is a glob and a regex:
|
|
27
|
+
#
|
|
28
|
+
# - It reads the project's own `signature_paths:` `.rbs` tree (the same stratum the envelope
|
|
29
|
+
# check reads) and matches {Effects::SignatureSources::ANNOTATION_HINT} line by line. No RBS
|
|
30
|
+
# parse, no analysis, no environment build, and nothing at all when the tree does not exist.
|
|
31
|
+
# - It consults the run's virtual RBS — rbs-inline's `# @rbs %a{…}` — only when the run ALREADY
|
|
32
|
+
# resolved an environment. Building one here to find an `:info` would cost more than the
|
|
33
|
+
# `:info` is worth, so the pass never reaches for a loader; every caller hands it what the run
|
|
34
|
+
# had. #441 is why the caller side matters: an ANALYZING run always has an environment, so the
|
|
35
|
+
# stratum is carried out of the analysis path as
|
|
36
|
+
# {Runner::RunSnapshots#effect_annotation_carrier} whatever its mode, and the two lanes of
|
|
37
|
+
# chapter 16 report the same annotation. The one shape that genuinely has no environment is a
|
|
38
|
+
# warm ADR-87 WD4 cache hit served by {Analysis::RunCacheProbe} — an engine-free path whose
|
|
39
|
+
# whole point is not building one — so there, and only there, an inline-only annotation goes
|
|
40
|
+
# unreported. Under-reporting is the fail-quiet direction; the check itself never depends on
|
|
41
|
+
# this pass.
|
|
42
|
+
#
|
|
43
|
+
# It is computed OUTSIDE the cached run assembly for the same reason `EffectEnvelopePass` is:
|
|
44
|
+
# the `effects:` block is deliberately absent from the diagnostics cache identity, so a residual
|
|
45
|
+
# baked into that entry would survive the very edit that answers it.
|
|
46
|
+
class EffectAnnotationResidualPass
|
|
47
|
+
NO_DIAGNOSTICS = [].freeze
|
|
48
|
+
private_constant :NO_DIAGNOSTICS
|
|
49
|
+
|
|
50
|
+
RULE = CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED
|
|
51
|
+
|
|
52
|
+
MESSAGE = "Effect annotations (`%a{pure}` / `%a{rigor:v1:effect …}`) are present in your " \
|
|
53
|
+
"project's signatures, but `.rigor.yml` carries no `effects:` block, so effect " \
|
|
54
|
+
"collection never runs and nothing checks them — they are documentation, not a " \
|
|
55
|
+
"contract. Add `effects: {}` to have Rigor prove what your methods do and check " \
|
|
56
|
+
"these bounds against it (ADR-103); an annotation alone never turns collection " \
|
|
57
|
+
"on, because that would make one line in one signature file more expensive for " \
|
|
58
|
+
"every run of the project."
|
|
59
|
+
private_constant :MESSAGE
|
|
60
|
+
|
|
61
|
+
# @param configuration [Rigor::Configuration]
|
|
62
|
+
# @param virtual_rbs [Array<Array(String, String)>, nil] `[buffer name, RBS source]` pairs the
|
|
63
|
+
# run ALREADY resolved; never a loader built for this pass. Empty / nil simply drops the
|
|
64
|
+
# virtual-RBS stratum.
|
|
65
|
+
def initialize(configuration:, virtual_rbs: nil)
|
|
66
|
+
@configuration = configuration
|
|
67
|
+
@virtual_rbs = virtual_rbs
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Array<Diagnostic>] zero or one.
|
|
71
|
+
def diagnostics
|
|
72
|
+
return NO_DIAGNOSTICS if @configuration.effects_enabled?
|
|
73
|
+
return NO_DIAGNOSTICS if @configuration.disabled_rules.include?(RULE)
|
|
74
|
+
|
|
75
|
+
sources = Effects::SignatureSources.collect(
|
|
76
|
+
signature_paths: @configuration.signature_paths, virtual_rbs: @virtual_rbs
|
|
77
|
+
)
|
|
78
|
+
return NO_DIAGNOSTICS if sources.empty?
|
|
79
|
+
|
|
80
|
+
found = Effects::SignatureSources.first_annotated(sources)
|
|
81
|
+
found.nil? ? NO_DIAGNOSTICS : [build_diagnostic(found)]
|
|
82
|
+
rescue StandardError
|
|
83
|
+
# Fail-soft, like every other effects surface: an advisory `:info` must never fail a run.
|
|
84
|
+
NO_DIAGNOSTICS
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Positioned at the first annotation itself rather than at `.rigor.yml:1`: the fix is a config
|
|
90
|
+
# edit, but the thing being reported is something the author wrote, and pointing at it is what
|
|
91
|
+
# tells them WHICH declaration is inert.
|
|
92
|
+
#
|
|
93
|
+
# A `virtual:` buffer's line numbers are the synthesized RBS's, not the `.rb`'s, so the position
|
|
94
|
+
# is re-anchored onto the Ruby file the same way the envelope reader anchors a declaration
|
|
95
|
+
# ({Effects::InlineAnchor}) — one mapping, so the advisory and the check can never name two
|
|
96
|
+
# different lines for one annotation.
|
|
97
|
+
def build_diagnostic(found)
|
|
98
|
+
name, content, line = found
|
|
99
|
+
path = Effects::SignatureSources.source_path(name)
|
|
100
|
+
Diagnostic.new(
|
|
101
|
+
path: path, line: Effects::InlineAnchor.ruby_line(path: path, buffer: content, buffer_line: line),
|
|
102
|
+
column: 1, message: MESSAGE, severity: :info, rule: RULE, source_family: :builtin
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../diagnostic"
|
|
6
|
+
require_relative "../check_rules"
|
|
7
|
+
require_relative "../../effects/config_envelopes"
|
|
8
|
+
require_relative "../../effects/envelope_check"
|
|
9
|
+
require_relative "../../effects/liskov_check"
|
|
10
|
+
require_relative "../../effects/method_key"
|
|
11
|
+
require_relative "../../effects/registry"
|
|
12
|
+
require_relative "../../effects/signature_sources"
|
|
13
|
+
require_relative "../../effects/unknown_label_check"
|
|
14
|
+
require_relative "../../rbs_extended/envelope_scanner"
|
|
15
|
+
require_relative "declaration_position"
|
|
16
|
+
require_relative "envelope_messages"
|
|
17
|
+
|
|
18
|
+
module Rigor
|
|
19
|
+
module Analysis
|
|
20
|
+
class Runner
|
|
21
|
+
# `effect.envelope-exceeded` and `effect.unknown-label`, end to end (ADR-103 WD1 / WD8 / WD12;
|
|
22
|
+
# #383 / #384).
|
|
23
|
+
#
|
|
24
|
+
# The two ride one gate and one walk on purpose. `effect.unknown-label` reports that an envelope
|
|
25
|
+
# STOPPED bounding — the fail-open degradation an unrecognised label causes — so opting into
|
|
26
|
+
# envelope enforcement is exactly what should turn it on, and reading it off any other pass
|
|
27
|
+
# would mean walking the project's signatures twice.
|
|
28
|
+
#
|
|
29
|
+
# Three steps, and the order is the cost model:
|
|
30
|
+
#
|
|
31
|
+
# 1. **Read the envelopes** off the project's own RBS ({RbsExtended::EnvelopeScanner}). A project
|
|
32
|
+
# with `effects:` but no envelope pays this walk and stops here.
|
|
33
|
+
# 2. **Force cross-file discovery**, but only when step 1 found something — the diagnostic is
|
|
34
|
+
# positioned at the Ruby `def`, and the discovery tables are what map a method key to one.
|
|
35
|
+
# 3. **Judge** ({Effects::EnvelopeCheck}) and render, then run the findings through the ordinary
|
|
36
|
+
# suppression filter so `# rigor:disable effect.envelope-exceeded` on the `def` line and
|
|
37
|
+
# `disable:` in `.rigor.yml` work exactly as they do for a per-file rule.
|
|
38
|
+
#
|
|
39
|
+
# **Why this is not part of the cached run assembly.** ADR-103 WD12 says envelope diagnostics are
|
|
40
|
+
# recomputed every run from the (possibly cached) summaries and never stored. The `effects:` block
|
|
41
|
+
# is deliberately absent from the diagnostics cache identity — that absence is what lets a project
|
|
42
|
+
# turn collection on without invalidating its check — so a finding baked into that entry would
|
|
43
|
+
# survive an `effects.check: false` edit. The pass therefore runs OUTSIDE
|
|
44
|
+
# `Runner#compute_run_diagnostics`, over whatever table the run ended up with, warm or cold.
|
|
45
|
+
class EffectEnvelopePass
|
|
46
|
+
NO_DIAGNOSTICS = [].freeze
|
|
47
|
+
private_constant :NO_DIAGNOSTICS
|
|
48
|
+
|
|
49
|
+
RULE = CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED
|
|
50
|
+
LISKOV_RULE = CheckRules::RULE_EFFECT_LISKOV_WIDENED
|
|
51
|
+
UNKNOWN_LABEL_RULE = CheckRules::RULE_EFFECT_UNKNOWN_LABEL
|
|
52
|
+
|
|
53
|
+
# Where a diagnostic about a configuration value is positioned when the loader cannot say
|
|
54
|
+
# which line the key was written on — the `rbs.coverage.quarantined-signature` precedent.
|
|
55
|
+
CONFIG_PATH = ".rigor.yml"
|
|
56
|
+
private_constant :CONFIG_PATH
|
|
57
|
+
|
|
58
|
+
RUBY_EXTENSION = ".rb"
|
|
59
|
+
private_constant :RUBY_EXTENSION
|
|
60
|
+
|
|
61
|
+
# What a `.rigor.yml` label list stops doing once one of its members is unrecognised.
|
|
62
|
+
TOLERATED_CONSEQUENCE = "the entry discharges nothing"
|
|
63
|
+
private_constant :TOLERATED_CONSEQUENCE
|
|
64
|
+
|
|
65
|
+
# An `envelopes[].effect` list degrades exactly as an annotation does: the whole tag reads ⊤.
|
|
66
|
+
ENVELOPE_CONSEQUENCE = "the entry now bounds nothing"
|
|
67
|
+
private_constant :ENVELOPE_CONSEQUENCE
|
|
68
|
+
|
|
69
|
+
# An attribution's labels are a *claim*, so an unrecognised member costs meaning rather than a
|
|
70
|
+
# bound: the labels are still attributed, and nothing in the vocabulary explains them.
|
|
71
|
+
ATTRIBUTION_CONSEQUENCE = "the attributed label means nothing to the vocabulary"
|
|
72
|
+
private_constant :ATTRIBUTION_CONSEQUENCE
|
|
73
|
+
|
|
74
|
+
# `effects.labels:` is the one list whose whole point is to introduce a spelling, so a member of
|
|
75
|
+
# it can only be unrecognised by being unregisterable — a root the loader dropped.
|
|
76
|
+
LABELS_CONSEQUENCE = "the label is not registered"
|
|
77
|
+
private_constant :LABELS_CONSEQUENCE
|
|
78
|
+
|
|
79
|
+
# @param configuration [Rigor::Configuration]
|
|
80
|
+
# @param rbs_loader [Rigor::Environment::RbsLoader, nil] the run's loader; nil disables the pass.
|
|
81
|
+
# @param effect_table [Rigor::Effects::EffectTable] the propagated graph.
|
|
82
|
+
# @param discovery [#call] forces and returns the cross-file discovery tables as
|
|
83
|
+
# `[def_sources, singleton_def_sources, class_sources]`. Called only when an envelope exists.
|
|
84
|
+
# @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path.
|
|
85
|
+
# @param unit_sources [Hash{String => Array<String>}] `Runner#effect_sources` — where each effect
|
|
86
|
+
# unit is defined, which is what an `effects.envelopes[].match:` path glob selects on. Its paths
|
|
87
|
+
# are relativised against the working directory, which is the project root for every run that
|
|
88
|
+
# reaches here (the same assumption `Snapshot.build` makes about `reach:`).
|
|
89
|
+
# @param ancestry [#call, nil] returns the run's as-written superclass table
|
|
90
|
+
# (`FileCollection#superclasses`) — the nominal relation `effect.liskov-widened` reads. A
|
|
91
|
+
# lambda, and called only once an envelope exists, because merging the run's collections is
|
|
92
|
+
# not free.
|
|
93
|
+
# @param apply_tolerated [Boolean] false runs the judgment with an empty tolerated set
|
|
94
|
+
# (`--no-tolerated-effects`).
|
|
95
|
+
def initialize(configuration:, rbs_loader:, effect_table:, discovery:, # rubocop:disable Metrics/ParameterLists
|
|
96
|
+
sources: nil, unit_sources: nil, ancestry: nil, apply_tolerated: true,
|
|
97
|
+
plugin_facts: nil)
|
|
98
|
+
@configuration = configuration
|
|
99
|
+
@plugin_facts = plugin_facts
|
|
100
|
+
@rbs_loader = rbs_loader
|
|
101
|
+
@effect_table = effect_table
|
|
102
|
+
@discovery = discovery
|
|
103
|
+
@sources = sources || {}
|
|
104
|
+
@unit_sources = unit_sources || {}
|
|
105
|
+
@ancestry_source = ancestry
|
|
106
|
+
@apply_tolerated = apply_tolerated
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Array<Diagnostic>] one per (method, exceeding label) plus one per unrecognised
|
|
110
|
+
# label, suppression already applied.
|
|
111
|
+
def diagnostics
|
|
112
|
+
return NO_DIAGNOSTICS unless @configuration.effects_check?
|
|
113
|
+
|
|
114
|
+
produced = config_label_diagnostics + config_envelope_diagnostics + declaration_diagnostics
|
|
115
|
+
return NO_DIAGNOSTICS if produced.empty?
|
|
116
|
+
|
|
117
|
+
suppress(produced)
|
|
118
|
+
rescue StandardError
|
|
119
|
+
# Fail-soft, like every other effects surface: an envelope Rigor cannot read must not fail a
|
|
120
|
+
# check that would otherwise pass.
|
|
121
|
+
NO_DIAGNOSTICS
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# The envelopes, from both strata, and the judgment over them.
|
|
127
|
+
#
|
|
128
|
+
# The `.rbs` / rbs-inline walk is the expensive half and stays lazy: a project with `effects:` and
|
|
129
|
+
# no annotation pays it and stops. It is also **skipped entirely** when there is no loader — a
|
|
130
|
+
# project that writes no RBS at all still gets the configured envelopes judged, which is the whole
|
|
131
|
+
# point of the convention surface (design note § 6.2, "value on day one").
|
|
132
|
+
def declaration_diagnostics
|
|
133
|
+
scan = scan_declarations
|
|
134
|
+
return NO_DIAGNOSTICS if scan.nil? && config_envelopes.empty?
|
|
135
|
+
|
|
136
|
+
unknown_label_diagnostics(scan) + exceeded_diagnostics(scan)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def scan_declarations
|
|
140
|
+
return nil if @rbs_loader.nil?
|
|
141
|
+
|
|
142
|
+
scan = RbsExtended::EnvelopeScanner.scan(sources: signature_sources, registry: registry)
|
|
143
|
+
scan.empty? ? nil : scan
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The envelope contract itself. Needs the propagated graph and the discovery tables the `def`
|
|
147
|
+
# positions come from, so it is skipped when collection produced nothing — an unrecognised
|
|
148
|
+
# label is still reported in that case, because it is a fact about the declaration alone.
|
|
149
|
+
#
|
|
150
|
+
# The two contracts ride one resolution of the strata and one force of the discovery tables:
|
|
151
|
+
# `effect.envelope-exceeded` asks whether a method honours its OWN bound, `effect.liskov-widened`
|
|
152
|
+
# whether an override honours the one it inherits, and both read the same distributed table.
|
|
153
|
+
def exceeded_diagnostics(scan)
|
|
154
|
+
return NO_DIAGNOSTICS if @effect_table.empty?
|
|
155
|
+
|
|
156
|
+
judge(scan).map { |finding| build_diagnostic(finding) } +
|
|
157
|
+
judge_liskov(scan).map { |finding| build_liskov_diagnostic(finding) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def unknown_label_diagnostics(scan)
|
|
161
|
+
return NO_DIAGNOSTICS if scan.nil?
|
|
162
|
+
|
|
163
|
+
findings = Effects::UnknownLabelCheck.for_envelopes(
|
|
164
|
+
method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
|
|
165
|
+
registry: registry
|
|
166
|
+
)
|
|
167
|
+
build_unknown_label_diagnostics(findings)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The `.rigor.yml` label lists. Their SHAPE is already a tier-2 load error; this is the other
|
|
171
|
+
# half, a member the registry does not know after plugin load, which would otherwise fail
|
|
172
|
+
# silently — a `tolerated:` entry that discharges nothing, an envelope that reads ⊤, an
|
|
173
|
+
# attribution nothing can interpret.
|
|
174
|
+
def config_label_diagnostics
|
|
175
|
+
findings = config_label_findings("effects.tolerated", @configuration.effects_tolerated,
|
|
176
|
+
TOLERATED_CONSEQUENCE) +
|
|
177
|
+
config_label_findings("effects.labels", @configuration.effects_labels,
|
|
178
|
+
LABELS_CONSEQUENCE) +
|
|
179
|
+
attribution_findings
|
|
180
|
+
build_unknown_label_diagnostics(findings)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def config_label_findings(key_path, labels, consequence)
|
|
184
|
+
return [] if labels.empty?
|
|
185
|
+
|
|
186
|
+
Effects::UnknownLabelCheck.for_config(
|
|
187
|
+
labels: labels, key_path: key_path, consequence: consequence, registry: registry
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def attribution_findings
|
|
192
|
+
@configuration.effects_attribution.flat_map do |key, labels|
|
|
193
|
+
config_label_findings("effects.attribution.#{key}", labels, ATTRIBUTION_CONSEQUENCE)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# `envelopes[].effect` — reported per entry, so the message names the stanza a reader has to edit
|
|
198
|
+
# rather than the label list it happens to share with another entry.
|
|
199
|
+
def config_envelope_diagnostics
|
|
200
|
+
findings = config_envelopes_entries.flat_map do |entry|
|
|
201
|
+
next [] if entry.unknown_labels.empty?
|
|
202
|
+
|
|
203
|
+
config_label_findings("effects.envelopes[#{entry.index}].effect", entry.labels,
|
|
204
|
+
ENVELOPE_CONSEQUENCE)
|
|
205
|
+
end
|
|
206
|
+
build_unknown_label_diagnostics(findings)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def build_unknown_label_diagnostics(findings)
|
|
210
|
+
findings.map { |finding| build_unknown_label_diagnostic(finding) }
|
|
211
|
+
.sort_by { |diagnostic| [diagnostic.path, diagnostic.line, diagnostic.message] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def build_unknown_label_diagnostic(finding)
|
|
215
|
+
path, line = position_of(finding)
|
|
216
|
+
Diagnostic.new(
|
|
217
|
+
path: path, line: line, column: 1, message: finding.message,
|
|
218
|
+
severity: :info, rule: UNKNOWN_LABEL_RULE, source_family: :builtin
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def position_of(finding)
|
|
223
|
+
DeclarationPosition.of(finding)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The vocabulary an unknown label is judged against: the shipped registry plus whatever
|
|
227
|
+
# `effects.labels:` opened, plus every loaded plugin's `effect_labels:` (#387). A project may open
|
|
228
|
+
# any root — the listing IS the vouching act — so nothing the project writes can raise on
|
|
229
|
+
# ownership; a plugin overreaching its root is refused inside {Effects::PluginFacts}, whose warning
|
|
230
|
+
# rides the report rather than this diagnostic stream.
|
|
231
|
+
def registry
|
|
232
|
+
@registry ||= Effects::Registry.for_configuration(@configuration, plugin_facts: @plugin_facts)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `effects.envelopes:`, resolved against the vocabulary once per run.
|
|
236
|
+
def config_envelopes_entries
|
|
237
|
+
@config_envelopes_entries ||= Effects::ConfigEnvelopes.build(
|
|
238
|
+
entries: @configuration.effects_envelopes, registry: registry
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The classes those entries select, as class-keyed envelopes. Keyed off the effect table's own
|
|
243
|
+
# class names, so an entry naming a namespace or a path with no analysed unit behind it simply
|
|
244
|
+
# selects nothing.
|
|
245
|
+
def config_envelopes
|
|
246
|
+
@config_envelopes ||= Effects::ConfigEnvelopes.for_classes(
|
|
247
|
+
entries: config_envelopes_entries, class_names: table_class_names, sources: @unit_sources
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def table_class_names
|
|
252
|
+
@effect_table.keys.filter_map { |key| Effects::MethodKey.owner(key) }.uniq
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def judge(scan)
|
|
256
|
+
Effects::EnvelopeCheck.run(
|
|
257
|
+
table: @effect_table,
|
|
258
|
+
method_envelopes: scan&.method_envelopes || {}, class_envelopes: scan&.class_envelopes || {},
|
|
259
|
+
config_envelopes: config_envelopes,
|
|
260
|
+
positions: positions,
|
|
261
|
+
apply_tolerated: @apply_tolerated
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def judge_liskov(scan)
|
|
266
|
+
Effects::LiskovCheck.run(
|
|
267
|
+
table: @effect_table, superclasses: ancestry,
|
|
268
|
+
method_envelopes: scan&.method_envelopes || {}, class_envelopes: scan&.class_envelopes || {},
|
|
269
|
+
config_envelopes: config_envelopes,
|
|
270
|
+
positions: positions,
|
|
271
|
+
apply_tolerated: @apply_tolerated
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The discovery tables both judgments position their findings from, forced once.
|
|
276
|
+
def positions
|
|
277
|
+
@positions ||= begin
|
|
278
|
+
def_sources, singleton_def_sources, class_sources = @discovery.call
|
|
279
|
+
Effects::EnvelopeCheck::Positions.build(
|
|
280
|
+
def_sources: def_sources, singleton_def_sources: singleton_def_sources,
|
|
281
|
+
class_sources: class_sources
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def ancestry
|
|
287
|
+
@ancestry ||= @ancestry_source&.call || {}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Everything an envelope may be written in — {Effects::SignatureSources} owns the stratum rule.
|
|
291
|
+
def signature_sources
|
|
292
|
+
Effects::SignatureSources.collect(
|
|
293
|
+
signature_paths: @configuration.signature_paths, virtual_rbs: @rbs_loader.virtual_rbs
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def build_diagnostic(finding)
|
|
298
|
+
Diagnostic.new(
|
|
299
|
+
path: finding.path || ".rigor.yml",
|
|
300
|
+
line: finding.line,
|
|
301
|
+
column: 1,
|
|
302
|
+
message: message_for(finding),
|
|
303
|
+
severity: :warning,
|
|
304
|
+
rule: RULE,
|
|
305
|
+
source_family: :builtin,
|
|
306
|
+
method_name: method_name_of(finding.key)
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def message_for(finding)
|
|
311
|
+
EnvelopeMessages.exceeded(finding)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def build_liskov_diagnostic(finding)
|
|
315
|
+
Diagnostic.new(
|
|
316
|
+
path: finding.path || CONFIG_PATH,
|
|
317
|
+
line: finding.line,
|
|
318
|
+
column: 1,
|
|
319
|
+
message: EnvelopeMessages.liskov(finding),
|
|
320
|
+
severity: :warning,
|
|
321
|
+
rule: LISKOV_RULE,
|
|
322
|
+
source_family: :builtin,
|
|
323
|
+
method_name: method_name_of(finding.key)
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def method_name_of(key)
|
|
328
|
+
index = key.index("#") || key.index(".")
|
|
329
|
+
index.nil? ? nil : key[(index + 1)..]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The ordinary suppression pipeline, per file: `# rigor:disable` / `# rigor:disable-file` comments
|
|
333
|
+
# from the Ruby file the diagnostic is positioned in, plus the project's `disable:` list. Reading
|
|
334
|
+
# the comments costs one parse per file that actually carries a finding.
|
|
335
|
+
def suppress(diagnostics)
|
|
336
|
+
diagnostics.group_by(&:path).flat_map do |path, group|
|
|
337
|
+
CheckRules.filter_suppressed(
|
|
338
|
+
group, comments: comments_for(path), disabled_rules: @configuration.disabled_rules
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Only a Ruby file has Ruby comments. An `effect.unknown-label` positioned in `.rbs` or at
|
|
344
|
+
# `.rigor.yml` is suppressible by the `disable:` list and by the baseline, never by an in-file
|
|
345
|
+
# comment — parsing YAML or RBS as Ruby to look for one would be a lie dressed as a feature.
|
|
346
|
+
def comments_for(path)
|
|
347
|
+
return [].freeze unless path.end_with?(RUBY_EXTENSION)
|
|
348
|
+
|
|
349
|
+
source = @sources[path]
|
|
350
|
+
result = source ? Prism.parse(source) : Prism.parse_file(path)
|
|
351
|
+
result.comments
|
|
352
|
+
rescue StandardError
|
|
353
|
+
[].freeze
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Analysis
|
|
5
|
+
class Runner
|
|
6
|
+
# How an effect-envelope finding is worded (ADR-103 WD14; #383 / #386).
|
|
7
|
+
#
|
|
8
|
+
# One module for both contracts, because their messages are one sentence with a swapped middle:
|
|
9
|
+
# `effect.envelope-exceeded` names the bound the method carries, `effect.liskov-widened` names the
|
|
10
|
+
# bound it inherits, and both end by naming the label that escaped. Keeping the two here is what
|
|
11
|
+
# stops them drifting into two dialects of the same explanation — and what keeps
|
|
12
|
+
# {EffectEnvelopePass} about *when* a finding is produced rather than about how it reads.
|
|
13
|
+
#
|
|
14
|
+
# The shape a reviewer can act on without re-running anything: what the method does, the shortest
|
|
15
|
+
# route to whatever proves it, the author's own spelling of the bound quoted back, and where that
|
|
16
|
+
# bound was written.
|
|
17
|
+
module EnvelopeMessages
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# `Effects::EnvelopeCheck::Finding` — a method against its own bound.
|
|
21
|
+
def exceeded(finding)
|
|
22
|
+
"Method #{finding.key} performs #{finding.label}#{explanation(finding)}, but is declared " \
|
|
23
|
+
"#{finding.envelope.spelling}#{declared_at(finding.envelope)}, so #{finding.label} exceeds " \
|
|
24
|
+
"the envelope."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `Effects::LiskovCheck::Finding` — an override against the bound it inherits. Two variants of
|
|
28
|
+
# one sentence, and which one a reader gets says which comparison produced the finding: what the
|
|
29
|
+
# override *does* against the inherited bound, or what it *declares* against it. Both name the
|
|
30
|
+
# ancestor's declaration, because that is where the fix usually goes.
|
|
31
|
+
def liskov(finding)
|
|
32
|
+
"Method #{finding.key} #{liskov_subject(finding)}, but overrides #{finding.ancestor_key}, " \
|
|
33
|
+
"which is declared #{finding.ancestor_envelope.spelling}" \
|
|
34
|
+
"#{declared_at(finding.ancestor_envelope)}, so #{finding.label} exceeds the inherited envelope."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def liskov_subject(finding)
|
|
38
|
+
own = finding.own_envelope
|
|
39
|
+
return "is declared #{own.spelling}#{declared_at(own)}" if own
|
|
40
|
+
|
|
41
|
+
"performs #{finding.label}#{explanation(finding)}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def explanation(finding)
|
|
45
|
+
hops = Array(finding.chain)[1..] || []
|
|
46
|
+
parts = [finding.origin, ("via #{hops.join(' → ')}" unless hops.empty?)].compact
|
|
47
|
+
parts.empty? ? "" : " (#{parts.join(' ')})"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A distributed annotation names the class it came from; a configured envelope does not, because
|
|
51
|
+
# its `location` already names the stanza (`.rigor.yml effects.envelopes[2]`) and the method key
|
|
52
|
+
# at the head of the message already names the class.
|
|
53
|
+
def declared_at(envelope)
|
|
54
|
+
owner = envelope.source == :class_annotation ? " on #{envelope.owner_key.split(/[#.]/).first}" : ""
|
|
55
|
+
where = envelope.location ? " at #{envelope.location}" : ""
|
|
56
|
+
"#{owner}#{where}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|