rigortype 0.3.2 → 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.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostic"
4
+ require_relative "../check_rules"
5
+ require_relative "../../effects/signature_sources"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ class Runner
10
+ # `effect.annotations-unchecked` — the ADR-103 WD13 commitment-1 residual (#384).
11
+ #
12
+ # WD13 fixes two rules that pull against each other: an annotation **must not** turn effect
13
+ # collection on (a `%a{pure}` written for Steep's benefit would otherwise impose a project-wide
14
+ # cost cliff on every run of every project that has one), and an annotation **must not** be
15
+ # silently inert either. One `:info` per run is the whole reconciliation: the declarations are
16
+ # there, nothing reads them, and `effects: {}` is the one-line answer.
17
+ #
18
+ # ## What it is allowed to cost
19
+ #
20
+ # This runs on the surface that is meant to be free — a project with no `effects:` block, which
21
+ # is every project by default — so the budget is a glob and a regex:
22
+ #
23
+ # - It reads the project's own `signature_paths:` `.rbs` tree (the same stratum the envelope
24
+ # check reads) and matches {Effects::SignatureSources::ANNOTATION_HINT} line by line. No RBS
25
+ # parse, no analysis, no environment build, and nothing at all when the tree does not exist.
26
+ # - It consults the run's virtual RBS — rbs-inline's `# @rbs %a{…}` — only when the run ALREADY
27
+ # 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.
31
+ #
32
+ # It is computed OUTSIDE the cached run assembly for the same reason `EffectEnvelopePass` is:
33
+ # the `effects:` block is deliberately absent from the diagnostics cache identity, so a residual
34
+ # baked into that entry would survive the very edit that answers it.
35
+ class EffectAnnotationResidualPass
36
+ NO_DIAGNOSTICS = [].freeze
37
+ private_constant :NO_DIAGNOSTICS
38
+
39
+ RULE = CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED
40
+
41
+ RUBY_EXTENSION = ".rb"
42
+ private_constant :RUBY_EXTENSION
43
+
44
+ MESSAGE = "Effect annotations (`%a{pure}` / `%a{rigor:v1:effect …}`) are present in your " \
45
+ "project's signatures, but `.rigor.yml` carries no `effects:` block, so effect " \
46
+ "collection never runs and nothing checks them — they are documentation, not a " \
47
+ "contract. Add `effects: {}` to have Rigor prove what your methods do and check " \
48
+ "these bounds against it (ADR-103); an annotation alone never turns collection " \
49
+ "on, because that would make one line in one signature file more expensive for " \
50
+ "every run of the project."
51
+ private_constant :MESSAGE
52
+
53
+ # @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)
57
+ @configuration = configuration
58
+ @rbs_loader = rbs_loader
59
+ end
60
+
61
+ # @return [Array<Diagnostic>] zero or one.
62
+ def diagnostics
63
+ return NO_DIAGNOSTICS if @configuration.effects_enabled?
64
+ return NO_DIAGNOSTICS if @configuration.disabled_rules.include?(RULE)
65
+
66
+ sources = Effects::SignatureSources.collect(
67
+ signature_paths: @configuration.signature_paths, virtual_rbs: @rbs_loader&.virtual_rbs
68
+ )
69
+ return NO_DIAGNOSTICS if sources.empty?
70
+
71
+ found = Effects::SignatureSources.first_annotated(sources)
72
+ found.nil? ? NO_DIAGNOSTICS : [build_diagnostic(found)]
73
+ rescue StandardError
74
+ # Fail-soft, like every other effects surface: an advisory `:info` must never fail a run.
75
+ NO_DIAGNOSTICS
76
+ end
77
+
78
+ private
79
+
80
+ # Positioned at the first annotation itself rather than at `.rigor.yml:1`: the fix is a config
81
+ # edit, but the thing being reported is something the author wrote, and pointing at it is what
82
+ # tells them WHICH declaration is inert.
83
+ def build_diagnostic(found)
84
+ name, content, line = found
85
+ path = Effects::SignatureSources.source_path(name)
86
+ line = inline_line(path, content, line) if path.end_with?(RUBY_EXTENSION)
87
+ Diagnostic.new(
88
+ path: path, line: line, column: 1, message: MESSAGE,
89
+ severity: :info, rule: RULE, source_family: :builtin
90
+ )
91
+ 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
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../diagnostic"
6
+ require_relative "../check_rules"
7
+ require_relative "../../effects/config_envelopes"
8
+ require_relative "../../effects/envelope_check"
9
+ require_relative "../../effects/liskov_check"
10
+ require_relative "../../effects/method_key"
11
+ require_relative "../../effects/registry"
12
+ require_relative "../../effects/signature_sources"
13
+ require_relative "../../effects/unknown_label_check"
14
+ require_relative "../../rbs_extended/envelope_scanner"
15
+ require_relative "declaration_position"
16
+ require_relative "envelope_messages"
17
+
18
+ module Rigor
19
+ module Analysis
20
+ class Runner
21
+ # `effect.envelope-exceeded` and `effect.unknown-label`, end to end (ADR-103 WD1 / WD8 / WD12;
22
+ # #383 / #384).
23
+ #
24
+ # The two ride one gate and one walk on purpose. `effect.unknown-label` reports that an envelope
25
+ # STOPPED bounding — the fail-open degradation an unrecognised label causes — so opting into
26
+ # envelope enforcement is exactly what should turn it on, and reading it off any other pass
27
+ # would mean walking the project's signatures twice.
28
+ #
29
+ # Three steps, and the order is the cost model:
30
+ #
31
+ # 1. **Read the envelopes** off the project's own RBS ({RbsExtended::EnvelopeScanner}). A project
32
+ # with `effects:` but no envelope pays this walk and stops here.
33
+ # 2. **Force cross-file discovery**, but only when step 1 found something — the diagnostic is
34
+ # positioned at the Ruby `def`, and the discovery tables are what map a method key to one.
35
+ # 3. **Judge** ({Effects::EnvelopeCheck}) and render, then run the findings through the ordinary
36
+ # suppression filter so `# rigor:disable effect.envelope-exceeded` on the `def` line and
37
+ # `disable:` in `.rigor.yml` work exactly as they do for a per-file rule.
38
+ #
39
+ # **Why this is not part of the cached run assembly.** ADR-103 WD12 says envelope diagnostics are
40
+ # recomputed every run from the (possibly cached) summaries and never stored. The `effects:` block
41
+ # is deliberately absent from the diagnostics cache identity — that absence is what lets a project
42
+ # turn collection on without invalidating its check — so a finding baked into that entry would
43
+ # survive an `effects.check: false` edit. The pass therefore runs OUTSIDE
44
+ # `Runner#compute_run_diagnostics`, over whatever table the run ended up with, warm or cold.
45
+ class EffectEnvelopePass
46
+ NO_DIAGNOSTICS = [].freeze
47
+ private_constant :NO_DIAGNOSTICS
48
+
49
+ RULE = CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED
50
+ LISKOV_RULE = CheckRules::RULE_EFFECT_LISKOV_WIDENED
51
+ UNKNOWN_LABEL_RULE = CheckRules::RULE_EFFECT_UNKNOWN_LABEL
52
+
53
+ # Where a diagnostic about a configuration value is positioned when the loader cannot say
54
+ # which line the key was written on — the `rbs.coverage.quarantined-signature` precedent.
55
+ CONFIG_PATH = ".rigor.yml"
56
+ private_constant :CONFIG_PATH
57
+
58
+ RUBY_EXTENSION = ".rb"
59
+ private_constant :RUBY_EXTENSION
60
+
61
+ # What a `.rigor.yml` label list stops doing once one of its members is unrecognised.
62
+ TOLERATED_CONSEQUENCE = "the entry discharges nothing"
63
+ private_constant :TOLERATED_CONSEQUENCE
64
+
65
+ # An `envelopes[].effect` list degrades exactly as an annotation does: the whole tag reads ⊤.
66
+ ENVELOPE_CONSEQUENCE = "the entry now bounds nothing"
67
+ private_constant :ENVELOPE_CONSEQUENCE
68
+
69
+ # An attribution's labels are a *claim*, so an unrecognised member costs meaning rather than a
70
+ # bound: the labels are still attributed, and nothing in the vocabulary explains them.
71
+ ATTRIBUTION_CONSEQUENCE = "the attributed label means nothing to the vocabulary"
72
+ private_constant :ATTRIBUTION_CONSEQUENCE
73
+
74
+ # `effects.labels:` is the one list whose whole point is to introduce a spelling, so a member of
75
+ # it can only be unrecognised by being unregisterable — a root the loader dropped.
76
+ LABELS_CONSEQUENCE = "the label is not registered"
77
+ private_constant :LABELS_CONSEQUENCE
78
+
79
+ # @param configuration [Rigor::Configuration]
80
+ # @param rbs_loader [Rigor::Environment::RbsLoader, nil] the run's loader; nil disables the pass.
81
+ # @param effect_table [Rigor::Effects::EffectTable] the propagated graph.
82
+ # @param discovery [#call] forces and returns the cross-file discovery tables as
83
+ # `[def_sources, singleton_def_sources, class_sources]`. Called only when an envelope exists.
84
+ # @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path.
85
+ # @param unit_sources [Hash{String => Array<String>}] `Runner#effect_sources` — where each effect
86
+ # unit is defined, which is what an `effects.envelopes[].match:` path glob selects on. Its paths
87
+ # are relativised against the working directory, which is the project root for every run that
88
+ # reaches here (the same assumption `Snapshot.build` makes about `reach:`).
89
+ # @param ancestry [#call, nil] returns the run's as-written superclass table
90
+ # (`FileCollection#superclasses`) — the nominal relation `effect.liskov-widened` reads. A
91
+ # lambda, and called only once an envelope exists, because merging the run's collections is
92
+ # not free.
93
+ # @param apply_tolerated [Boolean] false runs the judgment with an empty tolerated set
94
+ # (`--no-tolerated-effects`).
95
+ def initialize(configuration:, rbs_loader:, effect_table:, discovery:, # rubocop:disable Metrics/ParameterLists
96
+ sources: nil, unit_sources: nil, ancestry: nil, apply_tolerated: true,
97
+ plugin_facts: nil)
98
+ @configuration = configuration
99
+ @plugin_facts = plugin_facts
100
+ @rbs_loader = rbs_loader
101
+ @effect_table = effect_table
102
+ @discovery = discovery
103
+ @sources = sources || {}
104
+ @unit_sources = unit_sources || {}
105
+ @ancestry_source = ancestry
106
+ @apply_tolerated = apply_tolerated
107
+ end
108
+
109
+ # @return [Array<Diagnostic>] one per (method, exceeding label) plus one per unrecognised
110
+ # label, suppression already applied.
111
+ def diagnostics
112
+ return NO_DIAGNOSTICS unless @configuration.effects_check?
113
+
114
+ produced = config_label_diagnostics + config_envelope_diagnostics + declaration_diagnostics
115
+ return NO_DIAGNOSTICS if produced.empty?
116
+
117
+ suppress(produced)
118
+ rescue StandardError
119
+ # Fail-soft, like every other effects surface: an envelope Rigor cannot read must not fail a
120
+ # check that would otherwise pass.
121
+ NO_DIAGNOSTICS
122
+ end
123
+
124
+ private
125
+
126
+ # The envelopes, from both strata, and the judgment over them.
127
+ #
128
+ # The `.rbs` / rbs-inline walk is the expensive half and stays lazy: a project with `effects:` and
129
+ # no annotation pays it and stops. It is also **skipped entirely** when there is no loader — a
130
+ # project that writes no RBS at all still gets the configured envelopes judged, which is the whole
131
+ # point of the convention surface (design note § 6.2, "value on day one").
132
+ def declaration_diagnostics
133
+ scan = scan_declarations
134
+ return NO_DIAGNOSTICS if scan.nil? && config_envelopes.empty?
135
+
136
+ unknown_label_diagnostics(scan) + exceeded_diagnostics(scan)
137
+ end
138
+
139
+ def scan_declarations
140
+ return nil if @rbs_loader.nil?
141
+
142
+ scan = RbsExtended::EnvelopeScanner.scan(sources: signature_sources, registry: registry)
143
+ scan.empty? ? nil : scan
144
+ end
145
+
146
+ # The envelope contract itself. Needs the propagated graph and the discovery tables the `def`
147
+ # positions come from, so it is skipped when collection produced nothing — an unrecognised
148
+ # label is still reported in that case, because it is a fact about the declaration alone.
149
+ #
150
+ # The two contracts ride one resolution of the strata and one force of the discovery tables:
151
+ # `effect.envelope-exceeded` asks whether a method honours its OWN bound, `effect.liskov-widened`
152
+ # whether an override honours the one it inherits, and both read the same distributed table.
153
+ def exceeded_diagnostics(scan)
154
+ return NO_DIAGNOSTICS if @effect_table.empty?
155
+
156
+ judge(scan).map { |finding| build_diagnostic(finding) } +
157
+ judge_liskov(scan).map { |finding| build_liskov_diagnostic(finding) }
158
+ end
159
+
160
+ def unknown_label_diagnostics(scan)
161
+ return NO_DIAGNOSTICS if scan.nil?
162
+
163
+ findings = Effects::UnknownLabelCheck.for_envelopes(
164
+ method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
165
+ registry: registry
166
+ )
167
+ build_unknown_label_diagnostics(findings)
168
+ end
169
+
170
+ # The `.rigor.yml` label lists. Their SHAPE is already a tier-2 load error; this is the other
171
+ # half, a member the registry does not know after plugin load, which would otherwise fail
172
+ # silently — a `tolerated:` entry that discharges nothing, an envelope that reads ⊤, an
173
+ # attribution nothing can interpret.
174
+ def config_label_diagnostics
175
+ findings = config_label_findings("effects.tolerated", @configuration.effects_tolerated,
176
+ TOLERATED_CONSEQUENCE) +
177
+ config_label_findings("effects.labels", @configuration.effects_labels,
178
+ LABELS_CONSEQUENCE) +
179
+ attribution_findings
180
+ build_unknown_label_diagnostics(findings)
181
+ end
182
+
183
+ def config_label_findings(key_path, labels, consequence)
184
+ return [] if labels.empty?
185
+
186
+ Effects::UnknownLabelCheck.for_config(
187
+ labels: labels, key_path: key_path, consequence: consequence, registry: registry
188
+ )
189
+ end
190
+
191
+ def attribution_findings
192
+ @configuration.effects_attribution.flat_map do |key, labels|
193
+ config_label_findings("effects.attribution.#{key}", labels, ATTRIBUTION_CONSEQUENCE)
194
+ end
195
+ end
196
+
197
+ # `envelopes[].effect` — reported per entry, so the message names the stanza a reader has to edit
198
+ # rather than the label list it happens to share with another entry.
199
+ def config_envelope_diagnostics
200
+ findings = config_envelopes_entries.flat_map do |entry|
201
+ next [] if entry.unknown_labels.empty?
202
+
203
+ config_label_findings("effects.envelopes[#{entry.index}].effect", entry.labels,
204
+ ENVELOPE_CONSEQUENCE)
205
+ end
206
+ build_unknown_label_diagnostics(findings)
207
+ end
208
+
209
+ def build_unknown_label_diagnostics(findings)
210
+ findings.map { |finding| build_unknown_label_diagnostic(finding) }
211
+ .sort_by { |diagnostic| [diagnostic.path, diagnostic.line, diagnostic.message] }
212
+ end
213
+
214
+ def build_unknown_label_diagnostic(finding)
215
+ path, line = position_of(finding)
216
+ Diagnostic.new(
217
+ path: path, line: line, column: 1, message: finding.message,
218
+ severity: :info, rule: UNKNOWN_LABEL_RULE, source_family: :builtin
219
+ )
220
+ end
221
+
222
+ def position_of(finding)
223
+ DeclarationPosition.of(finding, 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