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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/docs/manual/02-cli-reference.md +60 -13
  6. data/docs/manual/03-configuration.md +20 -3
  7. data/docs/manual/04-diagnostics.md +3 -2
  8. data/docs/manual/11-ci.md +37 -0
  9. data/docs/manual/12-caching.md +39 -0
  10. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  11. data/docs/manual/19-effect-labels.md +671 -0
  12. data/docs/manual/README.md +5 -0
  13. data/docs/manual/ci-templates/README.md +9 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  15. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  16. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  17. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  18. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  19. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  20. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  21. data/lib/rigor/analysis/runner.rb +12 -3
  22. data/lib/rigor/analysis/worker_session.rb +3 -1
  23. data/lib/rigor/cli/effects_command.rb +123 -9
  24. data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
  25. data/lib/rigor/cli/effects_renderer.rb +41 -3
  26. data/lib/rigor/cli/effects_report.rb +116 -6
  27. data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
  28. data/lib/rigor/cli.rb +12 -1
  29. data/lib/rigor/configuration.rb +37 -25
  30. data/lib/rigor/configuration_error.rb +20 -0
  31. data/lib/rigor/effects/collector.rb +38 -1
  32. data/lib/rigor/effects/entry_points.rb +47 -1
  33. data/lib/rigor/effects/file_collection.rb +18 -4
  34. data/lib/rigor/effects/framework_units.rb +68 -13
  35. data/lib/rigor/effects/inline_anchor.rb +134 -0
  36. data/lib/rigor/effects/plugin_facts.rb +62 -13
  37. data/lib/rigor/effects/propagator.rb +79 -19
  38. data/lib/rigor/effects/registry.rb +10 -3
  39. data/lib/rigor/effects/scanner.rb +21 -9
  40. data/lib/rigor/effects/signature_sources.rb +16 -0
  41. data/lib/rigor/effects/snapshot.rb +21 -5
  42. data/lib/rigor/effects/taint_cause.rb +1 -0
  43. data/lib/rigor/effects/unit_scan.rb +87 -12
  44. data/lib/rigor/plugin/base.rb +4 -0
  45. data/lib/rigor/plugin/box.rb +18 -2
  46. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  47. data/lib/rigor/plugin/manifest.rb +34 -10
  48. data/lib/rigor/plugin/registry.rb +10 -3
  49. data/lib/rigor/rbs_extended.rb +22 -2
  50. data/lib/rigor/version.rb +1 -1
  51. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  52. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  53. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  54. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  55. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  56. 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. Mirrors the gemspec
40
- # `documentation_uri` URL scheme (`…/tree/main`).
41
- DOCUMENTATION_BASE = "https://github.com/rigortype/rigor/blob/main/docs/manual/04-diagnostics.md"
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(diagnostics: SeverityStamp.apply(diagnostics, @configuration), stats: nil)
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, #spelling]
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, sources: {})
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 = 1 unless line.positive?
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
- require_relative "../check_rules"
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 an inline-only annotation is reported when the run happens to have a
29
- # loader at hand and not otherwise. Under-reporting is the fail-quiet direction; the check
30
- # itself never depends on this pass.
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 rbs_loader [Rigor::Environment::RbsLoader, nil] a loader the run ALREADY has; never
55
- # one built for this pass. `nil` simply drops the virtual-RBS stratum.
56
- def initialize(configuration:, rbs_loader: nil)
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
- @rbs_loader = rbs_loader
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: @rbs_loader&.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: line, column: 1, message: MESSAGE,
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
@@ -220,7 +220,7 @@ module Rigor
220
220
  end
221
221
 
222
222
  def position_of(finding)
223
- DeclarationPosition.of(finding, sources: @sources)
223
+ DeclarationPosition.of(finding)
224
224
  end
225
225
 
226
226
  # The vocabulary an unknown label is judged against: the shipped registry plus whatever
@@ -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 four end-of-pass snapshots that the analysis paths compute as a side
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, and the `conforms-to` scan results
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(@plugin_registry, superclasses: table)
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 loader the run ALREADY resolved — never `envelope_rbs_loader`, which
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, rbs_loader: @run_environment&.rbs_loader
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, superclasses: @project_scope_seed[:discovered_superclasses] || {}
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
- table = analyze(configuration)
82
- report = EffectsReport.build(table, full: options.fetch(:full))
83
- EffectsRenderer.new(out: @out).render(report, format: options.fetch(:format))
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 exhaustive ones with no effects beyond mutate.local") do
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
- def analyze(configuration)
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(@argv.empty? ? configuration.paths : @argv)
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
- CLOSING_LINE = "Run `rigor effects update` and commit the result if this change is intended."
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