rigortype 0.3.4 → 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/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/docs/manual/02-cli-reference.md +60 -13
- data/docs/manual/03-configuration.md +20 -3
- data/docs/manual/04-diagnostics.md +3 -2
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +12 -3
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cli/effects_command.rb +123 -9
- data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
- data/lib/rigor/cli.rb +12 -1
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +16 -0
- data/lib/rigor/effects/snapshot.rb +21 -5
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- metadata +6 -1
|
@@ -26,6 +26,15 @@ comments (it works the same way against GitLab, Gerrit, Bitbucket, and Gitea
|
|
|
26
26
|
— see the [`rigor-ci-setup`](../../../skills/rigor-ci-setup/SKILL.md) skill).
|
|
27
27
|
All run Rigor the same way — only the output format and publish step differ.
|
|
28
28
|
|
|
29
|
+
Every template ends with a **commented-out `rigor effects check` step**.
|
|
30
|
+
Uncomment it once the project has an `effects:` block and a committed
|
|
31
|
+
`.rigor-effects.yml`, and CI will fail when a branch changes what the code
|
|
32
|
+
*does* — a job that starts talking to the network, a presenter that starts
|
|
33
|
+
querying. It ships commented because `effects check` exits `1` when the
|
|
34
|
+
snapshot file is absent, which is exactly what you want on a project that
|
|
35
|
+
has one and pure noise on a project that does not. The workflow around it
|
|
36
|
+
is [chapter 19, "Effect labels"](../19-effect-labels.md).
|
|
37
|
+
|
|
29
38
|
## Other runners (generic recipe)
|
|
30
39
|
|
|
31
40
|
On any CI system, the four steps are: provision Ruby 4.0, install
|
|
@@ -36,9 +36,16 @@ module Rigor
|
|
|
36
36
|
module RuleCatalog # rubocop:disable Metrics/ModuleLength
|
|
37
37
|
# Stable documentation home for a built-in rule. `documentation_url` appends a per-rule fragment that
|
|
38
38
|
# resolves to the rule's anchor in the published diagnostics catalogue; the page itself points at
|
|
39
|
-
# `rigor explain <rule>` as the authoritative per-rule reference.
|
|
40
|
-
#
|
|
41
|
-
|
|
39
|
+
# `rigor explain <rule>` as the authoritative per-rule reference.
|
|
40
|
+
#
|
|
41
|
+
# The canonical docs host, deliberately NOT a `github.com/…/blob/<ref>/…` path (ADR-65 amendment,
|
|
42
|
+
# #438): a git ref inside a frozen public contract is a mutable component, and the one that was
|
|
43
|
+
# baked in here — `main`, a branch this repository has never had — made every emitted URL 404 from
|
|
44
|
+
# the day the field shipped. A branch name rots on a rename and a tag only resolves once that tag
|
|
45
|
+
# is pushed (so every unreleased build would emit 404s); the published site carries no ref at all.
|
|
46
|
+
# The site renders `docs/manual/04-diagnostics.md` verbatim, `<a id="rule-…">` anchors included, so
|
|
47
|
+
# the fragment half of the contract is unchanged.
|
|
48
|
+
DOCUMENTATION_BASE = "https://rigor.typedduck.fail/manual/04-diagnostics/"
|
|
42
49
|
|
|
43
50
|
class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
|
|
44
51
|
:suppression, :severity_authored, :severity_by_profile,
|
|
@@ -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
|
|
@@ -10,17 +10,19 @@ module Rigor
|
|
|
10
10
|
# one (the `rbs_extended.unsatisfied-conformance` precedent) — there is no Ruby `def` that could
|
|
11
11
|
# carry the typo. A value written in configuration has no location at all and lands at
|
|
12
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.
|
|
13
18
|
module DeclarationPosition
|
|
14
19
|
CONFIG_PATH = ".rigor.yml"
|
|
15
|
-
RUBY_EXTENSION = ".rb"
|
|
16
|
-
private_constant :RUBY_EXTENSION
|
|
17
20
|
|
|
18
21
|
module_function
|
|
19
22
|
|
|
20
|
-
# @param finding [#location
|
|
21
|
-
# @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path
|
|
23
|
+
# @param finding [#location]
|
|
22
24
|
# @return [Array(String, Integer)] `[path, line]`
|
|
23
|
-
def of(finding
|
|
25
|
+
def of(finding)
|
|
24
26
|
location = finding.location
|
|
25
27
|
return [CONFIG_PATH, 1] if location.nil?
|
|
26
28
|
|
|
@@ -28,25 +30,7 @@ module Rigor
|
|
|
28
30
|
return [CONFIG_PATH, 1] if path.empty?
|
|
29
31
|
|
|
30
32
|
line = raw_line.to_i
|
|
31
|
-
line
|
|
32
|
-
return [path, line] unless path.end_with?(RUBY_EXTENSION)
|
|
33
|
-
|
|
34
|
-
[path, inline_line(path, finding.spelling, sources) || line]
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# rbs-inline's writer re-emits the author's own comment block ABOVE the annotation it generates,
|
|
38
|
-
# so a line number read out of the synthesized buffer drifts from the `.rb` line the author
|
|
39
|
-
# actually wrote — by the length of every method body above it. The annotation's own text is
|
|
40
|
-
# unique enough to find again, so the Ruby file is what answers. One read, and only when a
|
|
41
|
-
# finding already exists.
|
|
42
|
-
def inline_line(path, spelling, sources)
|
|
43
|
-
return nil if spelling.nil?
|
|
44
|
-
|
|
45
|
-
source = sources[path] || File.read(path)
|
|
46
|
-
source.each_line.with_index(1) { |line, number| return number if line.include?(spelling) }
|
|
47
|
-
nil
|
|
48
|
-
rescue StandardError
|
|
49
|
-
nil
|
|
33
|
+
[path, line.positive? ? line : 1]
|
|
50
34
|
end
|
|
51
35
|
end
|
|
52
36
|
end
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../diagnostic"
|
|
4
|
-
|
|
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"
|
|
5
10
|
require_relative "../../effects/signature_sources"
|
|
6
11
|
|
|
7
12
|
module Rigor
|
|
@@ -25,9 +30,15 @@ module Rigor
|
|
|
25
30
|
# parse, no analysis, no environment build, and nothing at all when the tree does not exist.
|
|
26
31
|
# - It consults the run's virtual RBS — rbs-inline's `# @rbs %a{…}` — only when the run ALREADY
|
|
27
32
|
# resolved an environment. Building one here to find an `:info` would cost more than the
|
|
28
|
-
# `:info` is worth, so
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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.
|
|
31
42
|
#
|
|
32
43
|
# It is computed OUTSIDE the cached run assembly for the same reason `EffectEnvelopePass` is:
|
|
33
44
|
# the `effects:` block is deliberately absent from the diagnostics cache identity, so a residual
|
|
@@ -38,9 +49,6 @@ module Rigor
|
|
|
38
49
|
|
|
39
50
|
RULE = CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED
|
|
40
51
|
|
|
41
|
-
RUBY_EXTENSION = ".rb"
|
|
42
|
-
private_constant :RUBY_EXTENSION
|
|
43
|
-
|
|
44
52
|
MESSAGE = "Effect annotations (`%a{pure}` / `%a{rigor:v1:effect …}`) are present in your " \
|
|
45
53
|
"project's signatures, but `.rigor.yml` carries no `effects:` block, so effect " \
|
|
46
54
|
"collection never runs and nothing checks them — they are documentation, not a " \
|
|
@@ -51,11 +59,12 @@ module Rigor
|
|
|
51
59
|
private_constant :MESSAGE
|
|
52
60
|
|
|
53
61
|
# @param configuration [Rigor::Configuration]
|
|
54
|
-
# @param
|
|
55
|
-
#
|
|
56
|
-
|
|
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)
|
|
57
66
|
@configuration = configuration
|
|
58
|
-
@
|
|
67
|
+
@virtual_rbs = virtual_rbs
|
|
59
68
|
end
|
|
60
69
|
|
|
61
70
|
# @return [Array<Diagnostic>] zero or one.
|
|
@@ -64,7 +73,7 @@ module Rigor
|
|
|
64
73
|
return NO_DIAGNOSTICS if @configuration.disabled_rules.include?(RULE)
|
|
65
74
|
|
|
66
75
|
sources = Effects::SignatureSources.collect(
|
|
67
|
-
signature_paths: @configuration.signature_paths, virtual_rbs: @
|
|
76
|
+
signature_paths: @configuration.signature_paths, virtual_rbs: @virtual_rbs
|
|
68
77
|
)
|
|
69
78
|
return NO_DIAGNOSTICS if sources.empty?
|
|
70
79
|
|
|
@@ -80,28 +89,19 @@ module Rigor
|
|
|
80
89
|
# Positioned at the first annotation itself rather than at `.rigor.yml:1`: the fix is a config
|
|
81
90
|
# edit, but the thing being reported is something the author wrote, and pointing at it is what
|
|
82
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.
|
|
83
97
|
def build_diagnostic(found)
|
|
84
98
|
name, content, line = found
|
|
85
99
|
path = Effects::SignatureSources.source_path(name)
|
|
86
|
-
line = inline_line(path, content, line) if path.end_with?(RUBY_EXTENSION)
|
|
87
100
|
Diagnostic.new(
|
|
88
|
-
path: path, line:
|
|
89
|
-
severity: :info, rule: RULE, source_family: :builtin
|
|
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
|
|
90
103
|
)
|
|
91
104
|
end
|
|
92
|
-
|
|
93
|
-
# A `virtual:` buffer's line numbers are the synthesized RBS's, not the `.rb`'s — rbs-inline
|
|
94
|
-
# re-emits the author's comment block above each generated member, so the two drift apart by
|
|
95
|
-
# the length of every body above. Find the annotation's own text in the Ruby file instead.
|
|
96
|
-
def inline_line(path, content, line)
|
|
97
|
-
spelling = content.each_line.to_a[line - 1].to_s[/%a\{[^}]*\}/]
|
|
98
|
-
return line if spelling.nil?
|
|
99
|
-
|
|
100
|
-
File.foreach(path).with_index(1) { |source_line, number| return number if source_line.include?(spelling) }
|
|
101
|
-
line
|
|
102
|
-
rescue StandardError
|
|
103
|
-
line
|
|
104
|
-
end
|
|
105
105
|
end
|
|
106
106
|
end
|
|
107
107
|
end
|
|
@@ -6,6 +6,7 @@ require_relative "../../environment"
|
|
|
6
6
|
require_relative "../diagnostic"
|
|
7
7
|
require_relative "../worker_session"
|
|
8
8
|
require_relative "../run_stats"
|
|
9
|
+
require_relative "../../effects/signature_sources"
|
|
9
10
|
require_relative "../../rbs_extended/conformance_checker"
|
|
10
11
|
require_relative "../../runtime/jit"
|
|
11
12
|
|
|
@@ -123,6 +124,7 @@ module Rigor
|
|
|
123
124
|
|
|
124
125
|
def analyze_files_sequentially(files, environment)
|
|
125
126
|
snapshot_project_signature_state(environment)
|
|
127
|
+
snapshot_effect_annotation_carrier(environment&.rbs_loader)
|
|
126
128
|
result = files.flat_map { |path| @analyze_file.call(path, environment) }
|
|
127
129
|
if @collect_stats
|
|
128
130
|
loader = environment.rbs_loader
|
|
@@ -157,6 +159,27 @@ module Rigor
|
|
|
157
159
|
@snapshots.conformance_results = RbsExtended::ConformanceChecker.scan(loader)
|
|
158
160
|
end
|
|
159
161
|
|
|
162
|
+
# #441 — the inline stratum of `effect.annotations-unchecked`, carried out of the analysis path as
|
|
163
|
+
# small plain data, for the same reason the block above is: the environment stays a LOCAL so it goes
|
|
164
|
+
# GC-eligible when the path returns, and the residual pass runs after it.
|
|
165
|
+
#
|
|
166
|
+
# {Runner#effect_annotation_residual_diagnostics} used to read the loader off `@run_environment`,
|
|
167
|
+
# which only the ADR-45 result-cacheable path ever assigns — so a `--no-cache`, `--workers N` or
|
|
168
|
+
# `--incremental` run reported a `.rbs` annotation and stayed silent about the identical one written
|
|
169
|
+
# as an rbs-inline comment, while a default run reported both. The environment was there either way;
|
|
170
|
+
# nothing was carrying it. {Effects::SignatureSources.annotated_carrier} reduces it to at most ONE
|
|
171
|
+
# synthesized buffer, which is all a one-`:info`-per-run pass can spend.
|
|
172
|
+
#
|
|
173
|
+
# Skipped when collection is on — then {Runner::EffectEnvelopePass} reads the loader directly and
|
|
174
|
+
# this pass is gated off — so a collecting run pays nothing, and a project with no synthesizing
|
|
175
|
+
# plugin answers with an empty array and no regex at all.
|
|
176
|
+
def snapshot_effect_annotation_carrier(loader)
|
|
177
|
+
virtual = @record_effects ? nil : loader&.virtual_rbs
|
|
178
|
+
@snapshots.effect_annotation_carrier = Effects::SignatureSources.annotated_carrier(virtual)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
@snapshots.effect_annotation_carrier = [].freeze
|
|
181
|
+
end
|
|
182
|
+
|
|
160
183
|
# Sequential-mode environment resolver. Returns the supplied `environment:` override (with the
|
|
161
184
|
# runner's fresh per-run reporter pair attached so dispatcher events route to THIS runner's
|
|
162
185
|
# diagnostics) when present; otherwise builds a fresh Environment per-call via
|
|
@@ -408,6 +431,7 @@ module Rigor
|
|
|
408
431
|
# Force the full RBS load on the parent so children copy-on-write inherit a warm Environment
|
|
409
432
|
# rather than each rebuilding it after the fork.
|
|
410
433
|
session.environment.rbs_loader&.prewarm
|
|
434
|
+
snapshot_effect_annotation_carrier(session.environment.rbs_loader)
|
|
411
435
|
snapshot_fork_pool_stats(session) if @collect_stats
|
|
412
436
|
|
|
413
437
|
worker_count = [@workers, files.size].min
|
|
@@ -540,6 +564,7 @@ module Rigor
|
|
|
540
564
|
# per-file analysis runs on the coordinator, identical to the default sequential path.
|
|
541
565
|
def analyze_files_sequentially_fallback(files, reason:)
|
|
542
566
|
environment = build_runner_environment
|
|
567
|
+
snapshot_effect_annotation_carrier(environment.rbs_loader)
|
|
543
568
|
diagnostics = files.flat_map { |path| @analyze_file.call(path, environment) }
|
|
544
569
|
loader = environment.rbs_loader
|
|
545
570
|
@snapshots.class_decl_paths = loader&.class_decl_paths || {}.freeze
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
module Rigor
|
|
4
4
|
module Analysis
|
|
5
5
|
class Runner
|
|
6
|
-
# Mutable per-run holder for the
|
|
6
|
+
# Mutable per-run holder for the end-of-pass snapshots that the analysis paths compute as a side
|
|
7
7
|
# effect and the rest of the run reads back: the RBS `class_decl_paths` / `signature_paths` tables
|
|
8
|
-
# (consumed by {RunStats}), the synthesized-namespace name list,
|
|
9
|
-
# (consumed by the diagnostic aggregator)
|
|
8
|
+
# (consumed by {RunStats}), the synthesized-namespace name list, the `conforms-to` scan results
|
|
9
|
+
# (consumed by the diagnostic aggregator), and the effect-annotation carrier (#441 — consumed by
|
|
10
|
+
# {EffectAnnotationResidualPass} on the runs that never store the environment).
|
|
10
11
|
#
|
|
11
12
|
# The snapshots are written by whichever analysis path ran ({PoolCoordinator} sequential / fork-pool /
|
|
12
13
|
# fallback) and read by the {Runner} and {DiagnosticAggregator}. A shared mutable holder keeps the
|
|
@@ -16,7 +17,7 @@ module Rigor
|
|
|
16
17
|
class RunSnapshots
|
|
17
18
|
attr_accessor :class_decl_paths, :signature_paths,
|
|
18
19
|
:synthesized_namespaces, :quarantined_signatures, :conformance_results,
|
|
19
|
-
:env_build_failure
|
|
20
|
+
:env_build_failure, :effect_annotation_carrier
|
|
20
21
|
|
|
21
22
|
# Constructor defaults match the {Runner} constructor: the pre-seed values `build_run_stats` /
|
|
22
23
|
# `pre_file_diagnostics` read before the first analysis path runs are frozen empties. The
|
|
@@ -29,6 +30,7 @@ module Rigor
|
|
|
29
30
|
@quarantined_signatures = [].freeze
|
|
30
31
|
@conformance_results = [].freeze
|
|
31
32
|
@env_build_failure = nil
|
|
33
|
+
@effect_annotation_carrier = [].freeze
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# Per-`#run` reset. Mirrors the original `#run` body, which reset these to NON-frozen empties (distinct
|
|
@@ -40,6 +42,7 @@ module Rigor
|
|
|
40
42
|
@quarantined_signatures = []
|
|
41
43
|
@conformance_results = []
|
|
42
44
|
@env_build_failure = nil
|
|
45
|
+
@effect_annotation_carrier = [].freeze
|
|
43
46
|
end
|
|
44
47
|
end
|
|
45
48
|
end
|
|
@@ -108,7 +108,9 @@ module Rigor
|
|
|
108
108
|
return @effect_plugin_facts if @effect_plugin_facts && @effect_plugin_facts_ancestry.equal?(table)
|
|
109
109
|
|
|
110
110
|
@effect_plugin_facts_ancestry = table
|
|
111
|
-
@effect_plugin_facts = Effects::PluginFacts.build(
|
|
111
|
+
@effect_plugin_facts = Effects::PluginFacts.build(
|
|
112
|
+
@plugin_registry, superclasses: table, includes: @project_discovered_includes
|
|
113
|
+
)
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
# The merged per-file collections behind {#effect_table} — the *direct* summaries, before the graph
|
|
@@ -751,13 +753,20 @@ module Rigor
|
|
|
751
753
|
).diagnostics
|
|
752
754
|
end
|
|
753
755
|
|
|
754
|
-
# The residual takes the
|
|
756
|
+
# The residual takes the virtual RBS the run ALREADY resolved — never `envelope_rbs_loader`, which
|
|
755
757
|
# builds one on demand. This runs on the effects-off path, where an environment build is a cost
|
|
756
758
|
# the project did not ask for; the `signature_paths:` `.rbs` stratum is always read, and the
|
|
757
759
|
# rbs-inline stratum rides whatever the run happened to have.
|
|
760
|
+
#
|
|
761
|
+
# #441 — two sources, because two shapes of run resolve an environment and only one of them keeps
|
|
762
|
+
# it: `@run_environment` is assigned solely by the ADR-45 result-cacheable path, while every
|
|
763
|
+
# ANALYZING run (`--no-cache`, `--workers N`, `--incremental`, an editor buffer) builds one inside
|
|
764
|
+
# {PoolCoordinator} and lets it go. The snapshot is that run's carrier, and taking it second
|
|
765
|
+
# preserves the cacheable path's byte-identical behaviour.
|
|
758
766
|
def effect_annotation_residual_diagnostics
|
|
759
767
|
EffectAnnotationResidualPass.new(
|
|
760
|
-
configuration: @configuration,
|
|
768
|
+
configuration: @configuration,
|
|
769
|
+
virtual_rbs: @run_environment&.rbs_loader&.virtual_rbs || @snapshots.effect_annotation_carrier
|
|
761
770
|
).diagnostics
|
|
762
771
|
end
|
|
763
772
|
|
|
@@ -187,7 +187,9 @@ module Rigor
|
|
|
187
187
|
# the same project ancestry the parent would have walked.
|
|
188
188
|
def effect_plugin_facts
|
|
189
189
|
@effect_plugin_facts ||= Effects::PluginFacts.build(
|
|
190
|
-
@plugin_registry,
|
|
190
|
+
@plugin_registry,
|
|
191
|
+
superclasses: @project_scope_seed[:discovered_superclasses] || {},
|
|
192
|
+
includes: @project_scope_seed[:discovered_includes] || {}
|
|
191
193
|
)
|
|
192
194
|
end
|
|
193
195
|
private :effect_plugin_facts
|
|
@@ -4,6 +4,9 @@ require "optionparser"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../configuration"
|
|
6
6
|
require_relative "../analysis/runner"
|
|
7
|
+
require_relative "../effects/plugin_facts"
|
|
8
|
+
require_relative "../effects/registry"
|
|
9
|
+
require_relative "../plugin/loader"
|
|
7
10
|
require_relative "../cache/store"
|
|
8
11
|
require_relative "command"
|
|
9
12
|
require_relative "effects_renderer"
|
|
@@ -61,7 +64,17 @@ module Rigor
|
|
|
61
64
|
#{USAGE}
|
|
62
65
|
|
|
63
66
|
With no subcommand, prints one line per method: its proven effect labels and whether that
|
|
64
|
-
list is exhaustive.
|
|
67
|
+
list is exhaustive. A PATH selects which methods are printed, never which are analysed.
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--config=PATH Path to the Rigor configuration file
|
|
71
|
+
--format=FORMAT Output format: text (default) or json
|
|
72
|
+
--full List every method, including the ones with nothing to say
|
|
73
|
+
--label=LABEL Only methods carrying LABEL (or a label under it), in either lane
|
|
74
|
+
--pure Only methods proven to do nothing beyond mutate.local
|
|
75
|
+
--limit=N Print at most N methods
|
|
76
|
+
--why Expand each method's unresolved reasons and declared-lane sources
|
|
77
|
+
--list-labels Print the effect vocabulary this project can name, and exit
|
|
65
78
|
|
|
66
79
|
Subcommands (the committed effect snapshot, ADR-103 WD7):
|
|
67
80
|
update Write the snapshot to effects.snapshot.path. Commit it; review its diff.
|
|
@@ -78,24 +91,69 @@ module Rigor
|
|
|
78
91
|
return usage_error("unsupported format: #{options.fetch(:format)}") unless FORMATS.include?(options[:format])
|
|
79
92
|
|
|
80
93
|
configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
return list_labels(configuration) if options.fetch(:list_labels)
|
|
95
|
+
|
|
96
|
+
scope = @argv.dup
|
|
97
|
+
table, sources = analyze(configuration, scope)
|
|
98
|
+
report = EffectsReport.build(
|
|
99
|
+
table, full: options.fetch(:full), sources: sources, scope: scope,
|
|
100
|
+
label: options.fetch(:label), pure: options.fetch(:pure), limit: options.fetch(:limit)
|
|
101
|
+
)
|
|
102
|
+
note_scope(scope, report, table)
|
|
103
|
+
EffectsRenderer.new(out: @out, why: options.fetch(:why)).render(report, format: options.fetch(:format))
|
|
84
104
|
0
|
|
85
105
|
end
|
|
86
106
|
|
|
107
|
+
# A path argument is a **view**, and the note says so (#439).
|
|
108
|
+
#
|
|
109
|
+
# It used to narrow the analysed set, and effect labels are transitive over whatever was analysed —
|
|
110
|
+
# so `rigor effects app/controllers/issues_controller.rb` reported `IssuesController#create: [] …?`
|
|
111
|
+
# where the whole-project run reported four labels and a declared lane. The weakened answer was
|
|
112
|
+
# indistinguishable from a genuinely effect-free method, and a path argument is the only tractability
|
|
113
|
+
# lever the report has, so it was the thing an adopter reached for first.
|
|
114
|
+
#
|
|
115
|
+
# The note goes to stderr rather than into the report: it is about the invocation, not about the
|
|
116
|
+
# code, and `--format json` and `rigor effects … > report.txt` both stay exactly what they were.
|
|
117
|
+
def note_scope(scope, report, table)
|
|
118
|
+
return if scope.empty?
|
|
119
|
+
|
|
120
|
+
if report.empty?
|
|
121
|
+
@err.puts("rigor: no effect unit is defined in #{scope.join(', ')} " \
|
|
122
|
+
"(a path selects what is printed, not what is analysed)")
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@err.puts("rigor: showing #{report.rows.length} of #{table.size} units, selected by " \
|
|
127
|
+
"#{scope.join(', ')}; a path narrows the printing and not the analysis, so every " \
|
|
128
|
+
"label is the one the whole-project run reports")
|
|
129
|
+
end
|
|
130
|
+
|
|
87
131
|
FORMATS = %w[text json].freeze
|
|
88
132
|
private_constant :FORMATS
|
|
89
133
|
|
|
90
134
|
def parse_options
|
|
91
|
-
options = { config: nil, format: "text", full: false, no_tolerated: false
|
|
135
|
+
options = { config: nil, format: "text", full: false, no_tolerated: false, label: [], pure: false,
|
|
136
|
+
limit: nil, why: false, list_labels: false }
|
|
92
137
|
OptionParser.new do |opts|
|
|
93
138
|
opts.banner = USAGE
|
|
94
139
|
Options.add_config(opts, options)
|
|
95
140
|
opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
|
|
96
|
-
opts.on("--full", "List every method, including
|
|
141
|
+
opts.on("--full", "List every method, including the ones with nothing to say") do
|
|
97
142
|
options[:full] = true
|
|
98
143
|
end
|
|
144
|
+
opts.on("--label=LABEL", "Only methods carrying LABEL (or a label under it), in either lane") do |value|
|
|
145
|
+
options[:label].concat(value.split(",").map(&:strip).reject(&:empty?))
|
|
146
|
+
end
|
|
147
|
+
opts.on("--pure", "Only methods proven to do nothing beyond mutate.local — the %a{pure} set") do
|
|
148
|
+
options[:pure] = true
|
|
149
|
+
end
|
|
150
|
+
opts.on("--limit=N", Integer, "Print at most N methods") { |value| options[:limit] = value }
|
|
151
|
+
opts.on("--why", "Expand each method's unresolved reasons and declared-lane sources") do
|
|
152
|
+
options[:why] = true
|
|
153
|
+
end
|
|
154
|
+
opts.on("--list-labels", "Print the effect vocabulary this project can name, and exit") do
|
|
155
|
+
options[:list_labels] = true
|
|
156
|
+
end
|
|
99
157
|
# Accepted here and deliberately inert, exactly as it is on `update`: the report is an
|
|
100
158
|
# observation, and observations are undischarged. Only a JUDGMENT reads `effects.tolerated:` —
|
|
101
159
|
# `rigor effects check` / `diff`, and `rigor check`'s envelope contract.
|
|
@@ -116,15 +174,71 @@ module Rigor
|
|
|
116
174
|
# the diagnostics entry serves the run and the #382 effects sidecar serves the collections, leaving
|
|
117
175
|
# only the fixpoint. Sequential is not a cache decision — the run-result cache declines pool mode —
|
|
118
176
|
# but a collecting run is pinned to the fork backend anyway, so `workers: 0` costs nothing here.
|
|
119
|
-
|
|
177
|
+
# #429 — the vocabulary, from the product rather than from a document the gem does not ship.
|
|
178
|
+
#
|
|
179
|
+
# Four configuration keys and two annotation forms all require typing an effect label, and until
|
|
180
|
+
# this there was no way to find out what the labels are: the manual's pointers resolve into
|
|
181
|
+
# `docs/type-specification/`, which `rigortype.gemspec` does not package. The registry is shipped
|
|
182
|
+
# data and already knows the answer; nothing surfaced it.
|
|
183
|
+
#
|
|
184
|
+
# It loads plugins but never analyses, because a plugin is where `rails.flash.write` comes from and
|
|
185
|
+
# a reader deciding what to write in `effects.envelopes:` needs to see it. A plugin that fails to
|
|
186
|
+
# load costs its labels and nothing else — `rigor plugins` is where that failure is reported.
|
|
187
|
+
def list_labels(configuration)
|
|
188
|
+
registry = project_registry(configuration)
|
|
189
|
+
@out.puts("Effect vocabulary — #{registry.labels.length} labels (vocabulary #{registry.vocabulary_version})")
|
|
190
|
+
registry.roots.each do |root|
|
|
191
|
+
@out.puts
|
|
192
|
+
@out.puts(root_heading(root, registry))
|
|
193
|
+
in_root = registry.labels.select { |label| Effects::Label.root(label) == root }
|
|
194
|
+
in_root.each_slice(4) { |slice| @out.puts(" #{slice.join(', ')}") }
|
|
195
|
+
end
|
|
196
|
+
@out.puts
|
|
197
|
+
@out.puts("A bound naming a label admits everything under it and nothing above it: `io` covers " \
|
|
198
|
+
"`io.db.read`, `io.db.read` does not cover `io`.")
|
|
199
|
+
0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A shipped root carries the registry's own one-liner; a root a plugin or this project opened
|
|
203
|
+
# carries where it came from instead, which is the more useful thing to say about it.
|
|
204
|
+
def root_heading(root, registry)
|
|
205
|
+
description = registry.descriptions[root]
|
|
206
|
+
return "#{root} — #{description}" if description
|
|
207
|
+
|
|
208
|
+
"#{root} — opened by a plugin or by this project's `effects.labels:`"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def project_registry(configuration)
|
|
212
|
+
services = Plugin::Services.new(reflection: Reflection, type: Type::Combinator,
|
|
213
|
+
configuration: configuration, cache_store: nil)
|
|
214
|
+
plugins = Plugin::Loader.load(configuration: configuration, services: services)
|
|
215
|
+
Effects::Registry.for_configuration(configuration, plugin_facts: Effects::PluginFacts.build(plugins))
|
|
216
|
+
rescue StandardError
|
|
217
|
+
Effects::Registry.for_configuration(configuration)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The analysed set is the configured `paths:` **plus** whatever the arguments name — never the
|
|
221
|
+
# arguments alone (#439). An effect summary is transitive over whatever was analysed, so analysing
|
|
222
|
+
# less does not filter the report, it lowers every answer in it: `rigor effects
|
|
223
|
+
# app/controllers/issues_controller.rb` used to report `IssuesController#create: [] …?` where the
|
|
224
|
+
# whole-project run reported four labels and a declared lane, with nothing marking the difference.
|
|
225
|
+
#
|
|
226
|
+
# The union rather than the configured paths alone, so that pointing the command at a tree the
|
|
227
|
+
# configuration does not cover — which is what every `rigor effects PATH` invocation from outside a
|
|
228
|
+
# project does — still analyses it. Inside a project the argument is already under `paths:` and the
|
|
229
|
+
# union is the configured set unchanged.
|
|
230
|
+
#
|
|
231
|
+
# @return [Array(Rigor::Effects::EffectTable, Hash{String=>Array<String>})] the table, and which
|
|
232
|
+
# file each unit was defined in — the map {EffectsReport} needs to answer a path argument.
|
|
233
|
+
def analyze(configuration, scope)
|
|
120
234
|
runner = Analysis::Runner.new(
|
|
121
235
|
configuration: configuration,
|
|
122
236
|
cache_store: Cache::Store.new(root: configuration.cache_path),
|
|
123
237
|
collect_stats: false,
|
|
124
238
|
workers: 0
|
|
125
239
|
)
|
|
126
|
-
runner.run(
|
|
127
|
-
runner.effect_table
|
|
240
|
+
runner.run((configuration.paths + scope).uniq)
|
|
241
|
+
[runner.effect_table, runner.effect_sources]
|
|
128
242
|
end
|
|
129
243
|
end
|
|
130
244
|
end
|
|
@@ -35,7 +35,11 @@ module Rigor
|
|
|
35
35
|
|
|
36
36
|
HEDGED_REMOVAL = "-? %<label>s (current summary is not exhaustive)"
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Both commands, because they answer the two questions a drift report raises and a reader almost
|
|
39
|
+
# always asks them in this order (#435). `explain` is the one the manual's own narrative reaches for
|
|
40
|
+
# first, and the footer used to name only the one that makes the report go away.
|
|
41
|
+
CLOSING_LINE = "Run `rigor effects explain` to see what caused this, and `rigor effects update` to " \
|
|
42
|
+
"accept it."
|
|
39
43
|
|
|
40
44
|
def initialize(out:, path:)
|
|
41
45
|
@out = out
|