rigortype 0.3.3 → 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 +6 -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/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 +158 -0
- 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/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/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 +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/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- 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/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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +79 -1
|
@@ -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, sources: @sources)
|
|
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
|
|
@@ -61,6 +61,11 @@ module Rigor
|
|
|
61
61
|
# slice's cross-file reads, marshalled back into `#collected_dependencies` for the runner's graph.
|
|
62
62
|
@record_dependencies = record_dependencies
|
|
63
63
|
@collected_dependencies = {}
|
|
64
|
+
# ADR-103 WD13 — effect collection, derived from the configuration exactly as `Runner` and
|
|
65
|
+
# `WorkerSession` derive it. A worker's per-file collections marshal back into
|
|
66
|
+
# `#collected_effects`, so a pooled run's effect graph is the sequential run's graph.
|
|
67
|
+
@record_effects = configuration.effects_enabled?
|
|
68
|
+
@collected_effects = {}
|
|
64
69
|
@collect_stats = collect_stats
|
|
65
70
|
@buffer = buffer
|
|
66
71
|
@environment_override = environment_override
|
|
@@ -81,6 +86,11 @@ module Rigor
|
|
|
81
86
|
# so the incremental session's dependency graph is refreshed whether analysis ran sequential or pooled.
|
|
82
87
|
attr_reader :collected_dependencies
|
|
83
88
|
|
|
89
|
+
# ADR-103 WD12 — the per-file {Effects::FileCollection}s the fork pool captured this run (empty
|
|
90
|
+
# unless the configuration carries an `effects:` block and a pool run occurred). The runner merges
|
|
91
|
+
# these with its own sequential records before the post-pool fixpoint.
|
|
92
|
+
attr_reader :collected_effects
|
|
93
|
+
|
|
84
94
|
# ADR-15 Phase 4b — pool mode is enabled when `@workers > 0`. Editor mode (`buffer:` non-nil)
|
|
85
95
|
# silently overrides pool mode to sequential: per design § "Ractor pool mode", the pool's warm-up
|
|
86
96
|
# cost dominates one-file wall time, so the pool gains nothing on a per-buffer invocation. The
|
|
@@ -179,8 +189,12 @@ module Rigor
|
|
|
179
189
|
# per-worker dependency records back, and the Ractor path additionally lacks the cross-file
|
|
180
190
|
# `project_scope_seed` its worker would need to record complete edges. Without `fork` (Windows) a
|
|
181
191
|
# recording run degrades to sequential, which records correctly through `@analyze_file`.
|
|
192
|
+
#
|
|
193
|
+
# An effects run (ADR-103 WD13) is pinned for exactly the same reason — the Ractor messages carry
|
|
194
|
+
# no side-table channel — and degrades the same way. The degrade is sound rather than merely safe:
|
|
195
|
+
# the sequential fallback still collects, so the effect graph is complete either way.
|
|
182
196
|
def dispatch_pool(files)
|
|
183
|
-
if @record_dependencies
|
|
197
|
+
if @record_dependencies || @record_effects
|
|
184
198
|
return analyze_files_in_fork_pool(files) if Process.respond_to?(:fork)
|
|
185
199
|
|
|
186
200
|
return analyze_files_sequentially_fallback(
|
|
@@ -250,6 +264,14 @@ module Rigor
|
|
|
250
264
|
# replayed into the runner-side accumulators via their `record_*` APIs, which dedupe on the same
|
|
251
265
|
# keys as a single-session run would. Net result: reporter state is identical to the sequential
|
|
252
266
|
# path.
|
|
267
|
+
#
|
|
268
|
+
# **This backend cannot analyse a file under rbs 4.x, and the blocker is upstream** (#414).
|
|
269
|
+
# `RBS::Namespace.[]` interns every namespace through a process-wide flyweight cache held in
|
|
270
|
+
# module ivars (`@intern_trie_absolute` / `@intern_trie_relative`, mutable by design), and reading
|
|
271
|
+
# an unshareable module ivar from a non-main Ractor is `Ractor::IsolationError` — so every file a
|
|
272
|
+
# worker touches comes back as an internal analyzer error. What the code below fixes is the
|
|
273
|
+
# failure MODE, not the backend: the run used to hang forever instead of saying anything. Reviving
|
|
274
|
+
# the backend needs an upstream change, which is why `pool_backend` keeps `fork` as the default.
|
|
253
275
|
def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
254
276
|
# Pre-warm class-level lazy memos on the MAIN Ractor. `Environment::ClassRegistry.default` is the
|
|
255
277
|
# default kwarg threaded through `Environment.new` inside each worker session; lazy-initialising
|
|
@@ -303,15 +325,30 @@ module Rigor
|
|
|
303
325
|
end
|
|
304
326
|
end
|
|
305
327
|
|
|
328
|
+
# #414 — every worker is monitored BEFORE any file is dispatched, so a worker that dies at
|
|
329
|
+
# construction is still observed. `Ractor#monitor` posts `:exited` / `:aborted` to this
|
|
330
|
+
# Ractor's default port, which is the same mailbox the workers push their results to.
|
|
331
|
+
pool.each { |worker| worker.monitor(Ractor.current.default_port) }
|
|
332
|
+
|
|
306
333
|
files.each_with_index { |path, index| pool[index % pool.size].send(path) }
|
|
307
334
|
pool.each { |worker| worker.send(nil) }
|
|
308
335
|
|
|
309
336
|
prepare_diagnostics = nil
|
|
310
337
|
results_by_path = {}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
338
|
+
terminated = 0
|
|
339
|
+
|
|
340
|
+
# Bounded by TERMINATION, not by `:done`. A worker that dies never says `:done`, and the loop
|
|
341
|
+
# that counted `:done` blocked on `Ractor.receive` for as long as the process lived — the whole
|
|
342
|
+
# backend hung that way once `Environment.for_project` started raising `Ractor::IsolationError`
|
|
343
|
+
# inside `WorkerSession#initialize` (#414). Monitor notices are bare Symbols; worker messages
|
|
344
|
+
# are Arrays, so the two never collide.
|
|
345
|
+
while terminated < pool.size
|
|
314
346
|
message = Ractor.receive
|
|
347
|
+
if message.is_a?(Symbol)
|
|
348
|
+
terminated += 1
|
|
349
|
+
next
|
|
350
|
+
end
|
|
351
|
+
|
|
315
352
|
case message.first
|
|
316
353
|
when :prepare
|
|
317
354
|
prepare_diagnostics ||= message.last
|
|
@@ -319,13 +356,27 @@ module Rigor
|
|
|
319
356
|
results_by_path[message[1]] = message[2]
|
|
320
357
|
when :done
|
|
321
358
|
merge_worker_reporters(message.last)
|
|
322
|
-
done_count += 1
|
|
323
359
|
end
|
|
324
360
|
end
|
|
325
361
|
|
|
326
|
-
|
|
362
|
+
# `join` re-raises an abnormal worker's exception as `Ractor::RemoteError`; the degrade below is
|
|
363
|
+
# this path's answer to that, so the raise carries no information the coordinator acts on.
|
|
364
|
+
pool.each do |worker|
|
|
365
|
+
worker.join
|
|
366
|
+
rescue Ractor::Error
|
|
367
|
+
nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# The files no worker reported — every file of a worker that died, and any file in flight when
|
|
371
|
+
# it did. Re-analysed in process, exactly as the fork backend re-analyses a dead child's slice.
|
|
372
|
+
degraded = files.reject { |path| results_by_path.key?(path) }
|
|
373
|
+
unless degraded.empty?
|
|
374
|
+
environment = build_runner_environment(source_files: files)
|
|
375
|
+
degraded.each { |path| results_by_path[path] = @analyze_file.call(path, environment) }
|
|
376
|
+
end
|
|
327
377
|
|
|
328
|
-
Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
378
|
+
diagnostics = Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
379
|
+
degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "ractor"))
|
|
329
380
|
end
|
|
330
381
|
|
|
331
382
|
# ADR-15 Amendment (2026-05-20) — fork-based worker pool, the active backend for `workers > 0`.
|
|
@@ -378,11 +429,12 @@ module Rigor
|
|
|
378
429
|
# The degraded slice was re-analysed on the parent session, so its dependency records live there,
|
|
379
430
|
# not in a child payload — fold them in alongside the successful children's.
|
|
380
431
|
@collected_dependencies.merge!(session.drain_dependencies) if @record_dependencies
|
|
432
|
+
@collected_effects.merge!(session.drain_effects) if @record_effects
|
|
381
433
|
end
|
|
382
434
|
|
|
383
435
|
diagnostics = Array(session.prepare_diagnostics) +
|
|
384
436
|
files.flat_map { |path| results_by_path.fetch(path, []) }
|
|
385
|
-
degraded.empty? ? diagnostics : diagnostics.unshift(
|
|
437
|
+
degraded.empty? ? diagnostics : diagnostics.unshift(pool_degraded_diagnostic(degraded.size, "fork"))
|
|
386
438
|
end
|
|
387
439
|
|
|
388
440
|
# Child-process body for {#analyze_files_in_fork_pool}. Analyses the slice with the
|
|
@@ -399,6 +451,9 @@ module Rigor
|
|
|
399
451
|
results = slice.to_h { |path| [path, session.analyze(path)] }
|
|
400
452
|
payload = { results: results, reporters: session.drain_reporters,
|
|
401
453
|
dependencies: session.drain_dependencies }
|
|
454
|
+
# ADR-103 WD13 — the effects slot exists only on a collecting run, so a normal `--workers N` run
|
|
455
|
+
# marshals exactly the payload it marshalled before the feature existed.
|
|
456
|
+
payload[:effects] = session.drain_effects if @record_effects
|
|
402
457
|
File.binwrite(out_path, Marshal.dump(payload))
|
|
403
458
|
exit!(0)
|
|
404
459
|
rescue StandardError
|
|
@@ -430,6 +485,7 @@ module Rigor
|
|
|
430
485
|
results_by_path.merge!(payload.fetch(:results))
|
|
431
486
|
merge_worker_reporters(payload.fetch(:reporters))
|
|
432
487
|
@collected_dependencies.merge!(payload.fetch(:dependencies, {})) if @record_dependencies
|
|
488
|
+
@collected_effects.merge!(payload.fetch(:effects, {})) if @record_effects
|
|
433
489
|
else
|
|
434
490
|
degraded.concat(child[:slice])
|
|
435
491
|
end
|
|
@@ -448,10 +504,12 @@ module Rigor
|
|
|
448
504
|
nil
|
|
449
505
|
end
|
|
450
506
|
|
|
451
|
-
|
|
507
|
+
# Both pool backends degrade the same way — the files a dead worker owed are re-analysed in
|
|
508
|
+
# process — so they say so the same way, naming which backend it was (#414).
|
|
509
|
+
def pool_degraded_diagnostic(count, backend)
|
|
452
510
|
Diagnostic.new(
|
|
453
511
|
path: ".rigor.yml", line: 1, column: 1,
|
|
454
|
-
message: "
|
|
512
|
+
message: "#{backend} pool degraded: #{count} file(s) re-analysed in-process " \
|
|
455
513
|
"after a worker exited abnormally",
|
|
456
514
|
severity: :warning, rule: "pool-degraded", source_family: :builtin
|
|
457
515
|
)
|