rigortype 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) 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 +6 -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/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_set"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # `effects.tolerated:` at judgment time — the discharge policy, **per origin** (ADR-103 WD1 / WD14;
8
+ # normative in `docs/type-specification/effect-labels.md` § Discharge by policy).
9
+ #
10
+ # The rule the whole slice turns on: **a bundle is discharged when ANY of its labels is tolerated.**
11
+ # An origin is one callee or one construct, and its labels are what that one thing does; tolerating
12
+ # what the origin was *for* frees the transport it came with. `Logger#info` is `io` + `telemetry`, so
13
+ # `tolerated: [telemetry]` discharges the whole bundle — the project has said "logging is fine", and
14
+ # the `io` in that bundle is logging. A `File.read` in the same body is a different origin with a
15
+ # different bundle, and its `io.fs.read` still counts. That is why summaries keep labels per origin at
16
+ # all ({Summary#bundles}); a flat label set cannot tell the two `io`s apart.
17
+ #
18
+ # Discharge is a **judgment**, never a record. The snapshot on disk holds undischarged sets, the
19
+ # collector attributes undischarged labels, and only the two consumers below subtract:
20
+ # {EnvelopeCheck} (through {Propagator}'s second lane) and {SnapshotDiff}. `--no-tolerated-effects` is
21
+ # the audit switch — the same judgment with {NONE} — and is what makes the policy inspectable
22
+ # (Steins ADR-0084 invariant 3).
23
+ class Discharge
24
+ # The identity policy: nothing is tolerated, so nothing is discharged. What
25
+ # `--no-tolerated-effects` judges with, and what a project that configured no `tolerated:` list
26
+ # always has.
27
+ def self.none
28
+ @none ||= new([])
29
+ end
30
+
31
+ def initialize(tolerated)
32
+ @tolerated = tolerated.is_a?(LabelSet) ? tolerated : LabelSet.new(Array(tolerated).map(&:to_s))
33
+ freeze
34
+ end
35
+
36
+ # Whether the policy discharges nothing at all — the fast path every project that wrote no
37
+ # `tolerated:` list takes, and the reason the second propagation lane costs zero when unconfigured.
38
+ def inert?
39
+ @tolerated.empty?
40
+ end
41
+
42
+ # Whether ONE origin's bundle is discharged: some member of it is tolerated (or subsumed by a
43
+ # tolerated label — `tolerated: [io]` discharges an `io.fs.read` bundle). An empty bundle is
44
+ # discharged by nothing, and never carries anything to discharge.
45
+ def discharges?(labels)
46
+ return false if inert?
47
+
48
+ labels.to_a.any? { |label| @tolerated.admits?(label) }
49
+ end
50
+
51
+ # The join of every bundle this policy does NOT discharge — what a judgment reads in place of
52
+ # {Summary#proven}. A label that arrives through both a discharged and an undischarged origin
53
+ # survives, because the undischarged origin proves it on its own.
54
+ def undischarged(bundles)
55
+ return flatten(bundles) if inert?
56
+
57
+ bundles.reduce(LabelSet::EMPTY) do |acc, (_origin, labels)|
58
+ discharges?(labels) ? acc : acc.join(labels)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def flatten(bundles)
65
+ bundles.each_value.reduce(LabelSet::EMPTY) { |acc, set| acc.join(set) }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_set"
4
+ require_relative "summary"
5
+
6
+ module Rigor
7
+ module Effects
8
+ # The whole-project effect graph after propagation: every method key the run collected, with its
9
+ # direct summary and its transitive closure (ADR-103 WD12).
10
+ #
11
+ # An EffectTable is **not a diagnostic and never enters `rigor check`'s stream**. It hangs off the
12
+ # runner for the report of this slice and the snapshot of #381 to read, exactly as
13
+ # [ADR-102](../adr/102-unused-code-reachability-report.md) draws the report-versus-diagnostic line.
14
+ class EffectTable
15
+ # One method's row.
16
+ #
17
+ # `direct` is the {Summary} the collector produced for this method's own body — what a snapshot
18
+ # records, because a diff over direct summaries stays attributable to the pull request's own lines.
19
+ # `proven` / `exhaustive` / `causes` are the transitive readings: this method's own labels joined
20
+ # with every project method it reaches, and the exhaustiveness bit ANDed along the same edges.
21
+ #
22
+ # `undischarged` is the same transitive reading with the origin bundles `effects.tolerated:`
23
+ # discharges removed at their source (#385; {Discharge}) — what a *judgment* reads, where `proven`
24
+ # is what the record holds. The two are the same set for a project that tolerates nothing, and
25
+ # `--no-tolerated-effects` is the switch that makes a judgment read `proven` anyway.
26
+ #
27
+ # `declared` is the transitive `≤` lane: it travels the same edges as `proven` (ADR-103 WD1), so a
28
+ # caller two hops above an attributed gem call reads the claim rather than only the taint it left.
29
+ # The lanes are kept **raw** here — a declared label a proven one already subsumes is dropped where
30
+ # output is rendered ({LabelSet#excluding_subsumed_by}), never in the table, because a further join
31
+ # has to see what was actually declared.
32
+ class Entry < Data.define(:key, :direct, :proven, :undischarged, :declared, :exhaustive, :causes,
33
+ :edges)
34
+ def initialize(undischarged: nil, declared: nil, **rest)
35
+ super(undischarged: undischarged || rest.fetch(:proven), declared: declared || LabelSet::EMPTY,
36
+ **rest)
37
+ end
38
+
39
+ # The declared labels worth printing beside `proven` — the rendering rule, in one place so the
40
+ # report and the snapshot cannot disagree about it.
41
+ def rendered_declared
42
+ declared.excluding_subsumed_by(proven)
43
+ end
44
+
45
+ def exhaustive?
46
+ exhaustive
47
+ end
48
+
49
+ # Whether the report omits this row by default: exhaustive, proving nothing beyond frame-local
50
+ # mutation, and claiming nothing the proven lane does not already admit ({Summary#trivial?} for
51
+ # the reasoning behind the declared half). `--full` lists it anyway.
52
+ def trivial?
53
+ exhaustive && proven.subsumed_by?(Summary::TRIVIAL_BOUND) && rendered_declared.empty?
54
+ end
55
+ end
56
+
57
+ EMPTY_ENTRIES = {}.freeze
58
+ private_constant :EMPTY_ENTRIES
59
+
60
+ def self.empty
61
+ @empty ||= new(EMPTY_ENTRIES)
62
+ end
63
+
64
+ # @param entries [Hash{String => Entry}]
65
+ def initialize(entries)
66
+ @entries = entries.sort_by { |key, _| key }.to_h.freeze
67
+ freeze
68
+ end
69
+
70
+ def [](key)
71
+ @entries[key]
72
+ end
73
+
74
+ def keys
75
+ @entries.keys
76
+ end
77
+
78
+ def each(&)
79
+ @entries.each_value(&)
80
+ end
81
+ include Enumerable
82
+
83
+ def size
84
+ @entries.size
85
+ end
86
+
87
+ def empty?
88
+ @entries.empty?
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
6
+ #
7
+ # `reach:` entries are of two kinds, told apart syntactically:
8
+ #
9
+ # - a **path glob** — anything carrying a glob or path character (`* ? [ ] / .`) — matched against the
10
+ # project-relative file a method is defined in, with the `rigor unused --entry-point` semantics
11
+ # (`File.fnmatch?` with `File::FNM_PATHNAME`, so `**` is the only way across a directory boundary);
12
+ # - a **preset name** — a bare `[a-z0-9_-]+` token — resolved here to the globs the preset stands for.
13
+ #
14
+ # Presets are named by the plugin that models a framework — rigor-actionpack's controller actions,
15
+ # rigor-activejob's `perform`, rigor-actionmailer's mailer methods, rigor-actioncable's channels —
16
+ # through the `effect_entry_points:` manifest field (#387), and registered here through
17
+ # {.register_all} once the plugin set is loaded. `Configuration` checks only that a `reach:` entry is
18
+ # SHAPED like a preset name; the existence check runs where the snapshot expands it, which is the
19
+ # first point at which the registered set is complete.
20
+ #
21
+ # Registration is process-global and happens before a snapshot is built, in the parent process — the
22
+ # same shape the other CLI-time registries take. Nothing here crosses a fork or a Ractor boundary.
23
+ module EntryPoints
24
+ # What makes a `reach:` entry a path glob rather than a preset name. Kept deliberately wide: a name
25
+ # that looks at all like a path is read as one, because a mistyped preset that matched a file would
26
+ # be the confusing failure.
27
+ GLOB_CHARACTERS = %r{[*?\[\]/.]}
28
+
29
+ # What a preset name may be spelled with, so a malformed name is rejected at registration rather
30
+ # than becoming an unfindable key.
31
+ NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
32
+
33
+ class Error < StandardError
34
+ end
35
+
36
+ @presets = {}
37
+
38
+ class << self
39
+ # Whether `entry` is a path glob rather than a preset name.
40
+ def glob?(entry)
41
+ GLOB_CHARACTERS.match?(entry)
42
+ end
43
+
44
+ # Registers `name` as standing for `globs`. Re-registering the same name with the same globs is a
45
+ # no-op, so a plugin loaded twice in one process does not raise.
46
+ def register(name, globs)
47
+ key = name.to_s
48
+ raise Error, "not a well-formed entry-point preset name: #{key.inspect}" unless NAME_PATTERN.match?(key)
49
+
50
+ patterns = Array(globs).map(&:to_s).uniq.sort.freeze
51
+ existing = @presets[key]
52
+ return key if existing == patterns
53
+ raise Error, "entry-point preset already registered with different globs: #{key.inspect}" if existing
54
+
55
+ @presets[key] = patterns
56
+ key
57
+ end
58
+
59
+ def known?(name)
60
+ @presets.key?(name.to_s)
61
+ end
62
+
63
+ # Whether `entry` is spelled like a preset name at all — the shape check `Configuration` runs at
64
+ # load, before any plugin has had the chance to register one.
65
+ def name?(entry)
66
+ NAME_PATTERN.match?(entry)
67
+ end
68
+
69
+ # Registers every `effect_entry_points:` preset the loaded plugins declare. Idempotent per name +
70
+ # glob set, so two runs in one process (the spec suite, the LSP) do not collide; two plugins
71
+ # claiming one name with different globs is a genuine conflict and raises.
72
+ def register_all(presets)
73
+ Array(presets).each { |preset| register(preset.name, preset.globs) }
74
+ end
75
+
76
+ # Every registered preset name, sorted. Empty in this slice.
77
+ def names
78
+ @presets.keys.sort.freeze
79
+ end
80
+
81
+ # The globs `name` stands for, or `nil` when nothing registered it.
82
+ def globs_for(name)
83
+ @presets[name.to_s]
84
+ end
85
+
86
+ # Drops every registration. For specs only — production code registers and never unregisters.
87
+ def reset!
88
+ @presets = {}
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_set"
4
+ require_relative "summary"
5
+
6
+ module Rigor
7
+ module Effects
8
+ # An author-declared upper bound on one method's effect labels (ADR-103 WD1; normative in
9
+ # `docs/type-specification/effect-labels.md` § Effect envelopes).
10
+ #
11
+ # Four facts and one seam:
12
+ #
13
+ # - {#owner_key} — the method key the envelope binds (`Class#m` / `Class.m`). A class-level
14
+ # envelope is read once and {#rebind}ed onto each method of that class discovery knows.
15
+ # - {#bound} — the {LabelSet} the method's proven labels must be subsumed by. {LabelSet::TOP}
16
+ # means "no envelope": the fail-open reading a tag carrying an unknown label degrades to.
17
+ # - {#source} — which spelling produced it: `:pure_annotation` (`%a{pure}`),
18
+ # `:effect_annotation` (`%a{rigor:v1:effect …}` on the method), `:class_annotation` (either
19
+ # spelling on the class / module declaration, which is also what {#rebind} stamps — what matters
20
+ # downstream is that the bound was distributed rather than written on the method) or
21
+ # `:config_envelope` (an `effects.envelopes:` entry, {ConfigEnvelopes}). The last one survives
22
+ # {#rebind}: a configured envelope is distributed by construction, so "distributed" is not the
23
+ # fact worth stamping — where it was written is.
24
+ # - {#location} — `path:line` of the annotation, or `.rigor.yml effects.envelopes[N]` for a
25
+ # configured one, so the diagnostic can name where the bound was written; {#spelling} is the
26
+ # author's own text, quoted back verbatim.
27
+ # - {#unknown_labels} — the well-formed-but-unrecognised spellings that made this envelope read
28
+ # ⊤ ([#384](https://github.com/rigortype/rigor/issues/384)'s `effect.unknown-label` reads it).
29
+ # Empty otherwise.
30
+ # - {#declared_labels} — every token the tag listed, in source order, recognised or not (empty
31
+ # for `%a{pure}`, whose bound is written by its spelling rather than by a list). It is what
32
+ # answers "was some OTHER member of this list known?", one of the four signals
33
+ # {LabelIntent} reads intent off.
34
+ #
35
+ # `mutate.local` is tolerated by **every** envelope, `%a{pure}` included: a method may freely
36
+ # mutate what its own frame allocated and never let escape.
37
+ class Envelope < Data.define(:owner_key, :bound, :source, :location, :spelling, :unknown_labels,
38
+ :declared_labels)
39
+ NO_LABELS = [].freeze
40
+ private_constant :NO_LABELS
41
+
42
+ # {#source} for a bound written in `.rigor.yml` rather than on a declaration.
43
+ CONFIG_SOURCE = :config_envelope
44
+
45
+ def self.build(owner_key:, bound:, source:, location: nil, spelling: nil, unknown_labels: NO_LABELS,
46
+ declared_labels: NO_LABELS)
47
+ new(
48
+ owner_key: owner_key, bound: bound, source: source, location: location,
49
+ spelling: spelling, unknown_labels: unknown_labels.uniq.sort.freeze,
50
+ declared_labels: declared_labels.dup.freeze
51
+ )
52
+ end
53
+
54
+ # Whether this reads as ⊤ — no bound at all. An unknown label degrades the whole tag here, so a
55
+ # typo suppresses findings rather than inventing them (the fail-open rule).
56
+ def top?
57
+ bound.top?
58
+ end
59
+
60
+ # Whether `label` is inside the bound. `mutate.local` always is.
61
+ def tolerates?(label)
62
+ bound.admits?(label) || Summary::TRIVIAL_BOUND.admits?(label)
63
+ end
64
+
65
+ # The members of `label_set` this envelope does NOT admit, sorted. One diagnostic per member.
66
+ def exceeded_by(label_set)
67
+ return NO_LABELS if top?
68
+
69
+ label_set.to_a.reject { |label| tolerates?(label) }
70
+ end
71
+
72
+ # Whether the bound was written in `.rigor.yml` rather than on a declaration.
73
+ def config?
74
+ source == CONFIG_SOURCE
75
+ end
76
+
77
+ # The same bound, attached to another method key — how a class-level envelope reaches each
78
+ # method of its class. The source becomes `:class_annotation`, because the distribution is the
79
+ # fact the diagnostic has to explain; a configured envelope keeps its own source, because for it
80
+ # distribution is the only mode there is and `.rigor.yml` is the fact worth naming.
81
+ def rebind(key)
82
+ config? ? with(owner_key: key) : with(owner_key: key, source: :class_annotation)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envelope"
4
+ require_relative "effect_table"
5
+ require_relative "method_key"
6
+ require_relative "path_finder"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # Judges each method's declared envelope against what the run actually proved (ADR-103 WD1 / WD8;
11
+ # #383). The one place `effect.envelope-exceeded` is decided.
12
+ #
13
+ # Three rules make this FP-safe, and each is load-bearing:
14
+ #
15
+ # - **It reads the PROVEN lane only**, never the declared one and never taint. A non-exhaustive
16
+ # summary reads "these effects, and possibly more" and contributes no finding of its own; what
17
+ # it *did* prove is still proven, so a proven label outside the bound still fires. That is "as
18
+ # strict as proven" ([robustness-principle.md](../../../docs/type-specification/robustness-principle.md)),
19
+ # and it is why an unresolved call can never manufacture one.
20
+ # - **The bound binds the method's CODE, transitively.** The envelope is a contract about what the
21
+ # method does, and a repository that calls a helper that calls `Net::HTTP` does perform HTTP. So
22
+ # the comparison is against {EffectTable::Entry#proven} — the fixpoint's closure over project
23
+ # callees — not against the unit's own direct summary.
24
+ # - **`mutate.local` is tolerated by every envelope**, `%a{pure}` included ({Envelope#tolerates?}).
25
+ #
26
+ # **`effects.tolerated:` discharges, per origin** (#385). The comparison reads
27
+ # {EffectTable::Entry#undischarged} rather than `proven`: the propagator has already dropped every
28
+ # origin bundle the policy discharges and closed the rest over the graph, so a `pure`-declared method
29
+ # that logs is silent under `tolerated: [telemetry]` while the `File.read` two lines down still fires.
30
+ # `apply_tolerated: false` — `--no-tolerated-effects` — judges against `proven` instead, which is the
31
+ # audit switch that makes the policy inspectable rather than invisible.
32
+ module EnvelopeCheck
33
+ # One (method, exceeding label) pair. `chain` is the shortest project-method path from the
34
+ # method to whatever proves the label and `origin` is the callee key or construct at its end —
35
+ # the same explanation `rigor effects explain` prints, so the diagnostic tells a reader where to
36
+ # look rather than only that something is wrong.
37
+ Finding = Data.define(:key, :label, :envelope, :path, :line, :chain, :origin)
38
+
39
+ # The discovery tables a finding's POSITION is read from, as one value — `discovered_def_sources`
40
+ # (`{class => {method_sym => "path:line"}}`), its `def self.x` mirror, and `discovered_class_sources`
41
+ # (`{class => Set[path]}`), the fallback for a method with no Ruby `def` at all. Kept together so the
42
+ # check's own signature stays about the judgment rather than about where a `def` lives.
43
+ class Positions < Data.define(:def_sources, :singleton_def_sources, :class_sources)
44
+ NONE = {}.freeze
45
+ private_constant :NONE
46
+
47
+ def self.empty
48
+ @empty ||= build
49
+ end
50
+
51
+ def self.build(def_sources: nil, singleton_def_sources: nil, class_sources: nil)
52
+ new(def_sources: def_sources || NONE, singleton_def_sources: singleton_def_sources || NONE,
53
+ class_sources: class_sources || NONE)
54
+ end
55
+
56
+ # Where a finding about `key` goes: the Ruby `def`, from the discovery tables (ADR-103 WD14 chose
57
+ # the `def` over the `.rbs` line deliberately — `# rigor:disable` reads only Ruby comments). A
58
+ # method with no `def` at all is a synthesized accessor, and the class's own source is the closest
59
+ # thing to a position it has.
60
+ #
61
+ # It lives on the value rather than in {EnvelopeCheck} because {LiskovCheck} positions its
62
+ # findings identically, and two spellings of "where the fix goes" would eventually disagree.
63
+ #
64
+ # @return [Array(String, Integer)] `[path, line]`; `[nil, 1]` for a key with no owner.
65
+ def for(key)
66
+ class_name, separator, selector = MethodKey.split(key)
67
+ return [nil, 1] if class_name.nil?
68
+
69
+ table = separator == "." ? singleton_def_sources : def_sources
70
+ site = table.dig(class_name, selector.to_sym)
71
+ return split_site(site) if site
72
+
73
+ [Array(class_sources[class_name]).first, 1]
74
+ end
75
+
76
+ private
77
+
78
+ def split_site(site)
79
+ path, _, line = site.to_s.rpartition(":")
80
+ return [site.to_s, 1] if path.empty?
81
+
82
+ [path, line.to_i.positive? ? line.to_i : 1]
83
+ end
84
+ end
85
+
86
+ NO_FINDINGS = [].freeze
87
+ private_constant :NO_FINDINGS
88
+
89
+ module_function
90
+
91
+ # @param table [EffectTable] the run's propagated graph.
92
+ # @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
93
+ # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
94
+ # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
95
+ # to the classes they select ({ConfigEnvelopes.for_classes}), to distribute at the lowest precedence.
96
+ # @param positions [Positions] the discovery tables a finding's `def` position is read from.
97
+ # @param apply_tolerated [Boolean] false judges against the undischarged-by-policy `proven` lane —
98
+ # the `--no-tolerated-effects` audit switch.
99
+ # @return [Array<Finding>] sorted by position then key then label, so a run explains identically twice.
100
+ def run(table:, method_envelopes:, class_envelopes:, config_envelopes: {},
101
+ positions: Positions.empty, apply_tolerated: true)
102
+ envelopes = distribute(table, method_envelopes, class_envelopes, config_envelopes)
103
+ return NO_FINDINGS if envelopes.empty?
104
+
105
+ findings = []
106
+ envelopes.each do |key, envelope|
107
+ collect(findings, table, key, envelope, positions, apply_tolerated)
108
+ end
109
+ findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
110
+ end
111
+
112
+ # Resolves the per-method envelope for every unit the table knows, **nearest wins**:
113
+ #
114
+ # per-method annotation > class-level annotation > `effects.envelopes:` entry
115
+ #
116
+ # The two class-shaped strata distribute identically — an envelope keyed by a class name reaches
117
+ # every method key of THAT Ruby class, so a subclass's keys never match and a module distributes to
118
+ # its own methods only — and are applied in that order, so a written annotation always wins over a
119
+ # convention. Which config entry a class matched was already decided by {ConfigEnvelopes.for_classes}.
120
+ #
121
+ # Public because {LiskovCheck} resolves the *ancestor's* envelope by exactly these rules: an
122
+ # inherited bound has to be the same bound the ancestor is itself held to, or the two checks would
123
+ # disagree about what the author wrote.
124
+ def distribute(table, method_envelopes, class_envelopes, config_envelopes)
125
+ resolved = {}
126
+ unless class_envelopes.empty? && config_envelopes.empty?
127
+ keys_by_class(table).each do |class_name, keys|
128
+ envelope = class_envelopes[class_name] || config_envelopes[class_name]
129
+ next if envelope.nil?
130
+
131
+ keys.each { |key| resolved[key] = envelope.rebind(key) }
132
+ end
133
+ end
134
+ method_envelopes.each { |key, envelope| resolved[key] = envelope if table[key] }
135
+ resolved
136
+ end
137
+
138
+ # `{class name => [method key]}` over the units the run collected — the "every method discovery
139
+ # knows" of the class-level distribution rule, including reopenings in other files and the
140
+ # synthesized `attr_*` / `define_method` members the effects scanner adds.
141
+ def keys_by_class(table)
142
+ table.keys.each_with_object({}) do |key, out|
143
+ owner = MethodKey.owner(key)
144
+ next if owner.nil?
145
+
146
+ (out[owner] ||= []) << key
147
+ end
148
+ end
149
+
150
+ def collect(findings, table, key, envelope, positions, apply_tolerated)
151
+ return if envelope.top?
152
+
153
+ entry = table[key]
154
+ return if entry.nil?
155
+
156
+ exceeding = envelope.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
157
+ return if exceeding.empty?
158
+
159
+ path, line = positions.for(key)
160
+ exceeding.each do |label|
161
+ trail = PathFinder.shortest(table, symbol: key, label: label)
162
+ findings << Finding.new(
163
+ key: key, label: label, envelope: envelope, path: path, line: line,
164
+ chain: trail&.chain || [key].freeze, origin: trail&.origin
165
+ )
166
+ end
167
+ end
168
+
169
+ private_class_method :keys_by_class, :collect
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config_envelopes"
4
+ require_relative "envelope"
5
+ require_relative "method_key"
6
+ require_relative "registry"
7
+ require_relative "signature_sources"
8
+
9
+ module Rigor
10
+ module Effects
11
+ # The envelopes a **call site** may import as a `≤` bound, looked up by the callee the typer named
12
+ # (ADR-103 WD6; #386; normative in `docs/type-specification/effect-labels.md` § The declared lane at
13
+ # call sites).
14
+ #
15
+ # {EnvelopeCheck} asks "what bounds THIS method's body?" and answers it from the project's own
16
+ # declarations alone, because that is the stratum a contract can be checked against. This index asks
17
+ # the other question — "what does the thing I am calling promise?" — and so reads one stratum more:
18
+ # an **accepted signature**'s annotation, from the built RBS environment. WD6's trust ladder puts
19
+ # both on the discharging side, and the two questions are kept in two objects because only the first
20
+ # one may ever produce a finding.
21
+ #
22
+ # Four sources, nearest-first, exactly the check's precedence with the accepted stratum appended:
23
+ #
24
+ # per-method annotation > class-level annotation > `effects.envelopes:` > accepted signature
25
+ #
26
+ # The carrier is **nominal**: the lookup is by the receiver's *static* class name as the typer
27
+ # projected it (or, for an implicit-self call, by the unit's own class), never by walking ancestors.
28
+ # A structural interface erases to `Dynamic[top]` today and has nothing to attach a bound to
29
+ # ([ADR-103](../../../docs/adr/103-effect-labels.md) WD6); an inherited envelope reaches an override
30
+ # through `effect.liskov-widened` instead, which is a judgment rather than an import.
31
+ #
32
+ # Two bounds of the slice, both deliberate:
33
+ #
34
+ # - An `effects.envelopes:` entry selected by **`match:`** does not participate here. A path glob is
35
+ # a fact about where a class is *defined*, which a per-file collection window cannot see without
36
+ # the whole-project class-source table; `namespace:` needs only the name and does participate. The
37
+ # entry still bounds its own classes' methods for {EnvelopeCheck} and the Liskov check.
38
+ # - Lookup is by the *exact* owner. `repo.find` on a receiver typed `PgRepo` does not import
39
+ # `Repo#find`'s bound.
40
+ #
41
+ # Values are Marshal-clean ({Envelope} over frozen Strings and {LabelSet}s), because the index is
42
+ # built per process — the parent and each fork-pool worker build their own from the same
43
+ # configuration and the same signature content, so the two agree without a channel to keep in sync.
44
+ class EnvelopeIndex
45
+ NO_ENVELOPES = {}.freeze
46
+ private_constant :NO_ENVELOPES
47
+
48
+ NO_ENTRIES = [].freeze
49
+ private_constant :NO_ENTRIES
50
+
51
+ # The index a run with no declaration of any kind uses — and the fail-soft answer for a build that
52
+ # raised. Every lookup on it is one `empty?` read.
53
+ def self.empty
54
+ @empty ||= new
55
+ end
56
+
57
+ # Reads every stratum this index serves, once per process.
58
+ #
59
+ # @param configuration [Rigor::Configuration]
60
+ # @param plugin_facts [Rigor::Effects::PluginFacts, nil] the loaded plugins' effect contributions
61
+ # (#387); their `effect_labels:` join the vocabulary an annotation is read against, so a gem's
62
+ # `%a{rigor:v1:effect rails.activejob.enqueue}` resolves rather than reading as unknown.
63
+ # @param environment [Rigor::Environment, nil] the run's environment. Its loader supplies the
64
+ # rbs-inline / plugin `virtual_rbs` buffers and the built RBS environment the accepted stratum is
65
+ # read from; without one, both are simply absent (the fail-quiet direction — a missing `≤` bound
66
+ # costs precision, never a finding).
67
+ # @return [EnvelopeIndex]
68
+ def self.build(configuration:, environment: nil, plugin_facts: nil)
69
+ # Required here rather than at the top of the file: the reader pulls in the whole
70
+ # `RbsExtended` surface, and this build runs only under an `effects:` block.
71
+ require_relative "../rbs_extended/envelope_scanner"
72
+
73
+ registry = Registry.for_configuration(configuration, plugin_facts: plugin_facts)
74
+ loader = environment&.rbs_loader
75
+ scan = RbsExtended::EnvelopeScanner.scan(
76
+ sources: SignatureSources.collect(
77
+ signature_paths: configuration.signature_paths, virtual_rbs: loader&.virtual_rbs
78
+ ),
79
+ registry: registry
80
+ )
81
+ new(
82
+ method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
83
+ config_entries: ConfigEnvelopes.build(entries: configuration.effects_envelopes, registry: registry),
84
+ accepted: accepted_for(loader, registry)
85
+ )
86
+ rescue StandardError
87
+ empty
88
+ end
89
+
90
+ # The accepted stratum — every `%a{pure}` / `%a{rigor:v1:effect …}` on a method definition in the
91
+ # **built** RBS environment: a gem's shipped signatures, Rigor's bundled overlays, core RBS.
92
+ #
93
+ # It is read-only by construction. Nothing here can bound a project method's body, because the
94
+ # contract check ({EnvelopeCheck}) reads {RbsExtended::EnvelopeScanner}'s project-source tables and
95
+ # never this one; what an accepted signature can do is state what a call into it promises. The
96
+ # project's own signatures are in the built environment too and therefore appear here as well —
97
+ # harmlessly, since the project strata are consulted first and both discharge.
98
+ def self.accepted_for(loader, registry)
99
+ return NO_ENVELOPES if loader.nil?
100
+
101
+ RbsExtended::EnvelopeScanner.from_loader(loader: loader, registry: registry)
102
+ rescue StandardError
103
+ NO_ENVELOPES
104
+ end
105
+ private_class_method :accepted_for
106
+
107
+ def initialize(method_envelopes: NO_ENVELOPES, class_envelopes: NO_ENVELOPES,
108
+ config_entries: NO_ENTRIES, accepted: NO_ENVELOPES)
109
+ @method_envelopes = method_envelopes.freeze
110
+ @class_envelopes = class_envelopes.freeze
111
+ @config_entries = config_entries.reject { |entry| entry.namespace.nil? }.freeze
112
+ @accepted = accepted.freeze
113
+ @config_cache = {}
114
+ freeze
115
+ end
116
+
117
+ # Whether no stratum has anything to say. The scan's fast path: a project with no envelope of any
118
+ # kind pays one predicate per call site and nothing else.
119
+ def empty?
120
+ @method_envelopes.empty? && @class_envelopes.empty? && @config_entries.empty? && @accepted.empty?
121
+ end
122
+
123
+ # The envelope bounding `owner`'s `selector`, or nil.
124
+ #
125
+ # @param owner [String] the receiver's static class name, as the typer projected it
126
+ # @param singleton [Boolean] whether the call is `Owner.selector` rather than `Owner#selector`
127
+ # @param selector [String]
128
+ # @return [Envelope, nil] never a ⊤ envelope: a bound that bounds nothing is not a bound, and
129
+ # importing it would both add nothing and discharge a taint on the strength of a typo.
130
+ def [](owner, singleton, selector)
131
+ return nil if owner.nil? || empty?
132
+
133
+ key = "#{owner}#{singleton ? '.' : '#'}#{selector}"
134
+ envelope = @method_envelopes[key] || @class_envelopes[owner] || config_envelope(owner) ||
135
+ @accepted[key]
136
+ return nil if envelope.nil? || envelope.top?
137
+
138
+ envelope
139
+ end
140
+
141
+ private
142
+
143
+ # The first `namespace:` entry selecting `owner`, memoised per class name — one call site's owner
144
+ # is asked for once per site and a project has a handful of entries.
145
+ def config_envelope(owner)
146
+ return nil if @config_entries.empty?
147
+
148
+ if @config_cache.key?(owner)
149
+ @config_cache[owner]
150
+ else
151
+ entry = @config_entries.find { |candidate| ConfigEnvelopes.namespace_match?(candidate.namespace, owner) }
152
+ @config_cache[owner] = entry && ConfigEnvelopes.envelope_for(entry, owner)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end