rigortype 0.3.3 → 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 (171) 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 +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  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 +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # Maps a position read out of a **synthesized** RBS buffer back onto the Ruby file the author
6
+ # actually wrote in ([#432](https://github.com/rigortype/rigor/issues/432)).
7
+ #
8
+ # An rbs-inline annotation never reaches the envelope reader as the line the author typed. The
9
+ # plugin hands `RBS::Inline::Writer`'s output to the loader as a `virtual:rbs-inline:<path>.rb`
10
+ # buffer, and that output is a *fresh document*: bodies are gone, the members it emits are the
11
+ # signatures rather than the `def`s, and every annotation is re-emitted above its member on a line
12
+ # of the writer's choosing. The buffer name still names the `.rb`, so a location rendered straight
13
+ # off `RBS::Location` reads `app/models/change.rb:5` — the right file, and a line number that
14
+ # belongs to a document nobody has. On a file with a licence header it lands inside the copyright
15
+ # notice and is not even visibly wrong.
16
+ #
17
+ # ## Why the answer is the annotation's own text, matched by ordinal
18
+ #
19
+ # There is no line table to consult: upstream's writer emits text and keeps no mapping, and the
20
+ # synthesized buffer is what the RBS parser saw. What both documents *do* share is the author's own
21
+ # spelling — `%a{pure}` reaches the buffer verbatim — and their order, because the writer emits
22
+ # members in source order. So the k-th `%a{pure}` of the buffer is the k-th `%a{pure}` of the `.rb`,
23
+ # and matching on the ordinal is what keeps a file with several identically-bounded methods from
24
+ # pointing all of them at the first one. A plain "find the text" search cannot tell them apart, and
25
+ # a fixed offset would encode one fixture's licence header as if it were a rule.
26
+ #
27
+ # Two filters make the two lists comparable:
28
+ #
29
+ # - in the buffer, only a line whose own text *is* the annotation counts. The writer also echoes the
30
+ # author's comment block above each member, so `# @rbs %a{pure}` appears there too and would
31
+ # double every count;
32
+ # - in the `.rb`, only a comment line counts, so a `%a{…}` inside a string literal or a heredoc is
33
+ # never mistaken for a declaration.
34
+ #
35
+ # Every step degrades to the buffer line it was given: a `.rb` that cannot be read, a spelling with
36
+ # no match, a writer that some day stops preserving order. A position one line off is a smaller
37
+ # harm than a raised exception on a run that had a real finding to report.
38
+ class InlineAnchor
39
+ RUBY_EXTENSION = ".rb"
40
+ private_constant :RUBY_EXTENSION
41
+
42
+ # An RBS annotation as the parser sees it: the line's own text, not a comment mentioning one.
43
+ ANNOTATION_PREFIX = "%a{"
44
+ private_constant :ANNOTATION_PREFIX
45
+
46
+ COMMENT_PREFIX = "#"
47
+ private_constant :COMMENT_PREFIX
48
+
49
+ SPELLING_PATTERN = /%a\{[^}]*\}/
50
+ private_constant :SPELLING_PATTERN
51
+
52
+ # @param path [String] the buffer's readable path, as {SignatureSources.source_path} renders it.
53
+ # @param buffer [String] the synthesized RBS the position was read out of.
54
+ # @return [InlineAnchor, nil] nil when `path` is not a Ruby file — a real `.rbs` needs no mapping,
55
+ # and its own line numbers are already the ones a reader can open.
56
+ def self.for(path:, buffer:)
57
+ return nil unless path.to_s.end_with?(RUBY_EXTENSION)
58
+
59
+ new(path: path.to_s, buffer: buffer.to_s)
60
+ end
61
+
62
+ # One-shot form, for a caller that holds a `[path, buffer, line]` triple and no anchor.
63
+ #
64
+ # @return [Integer] the `.rb` line, or `buffer_line` unchanged when there is nothing to map.
65
+ def self.ruby_line(path:, buffer:, buffer_line:, spelling: nil)
66
+ anchor = self.for(path: path, buffer: buffer)
67
+ return buffer_line if anchor.nil?
68
+
69
+ anchor.line_for(buffer_line, spelling: spelling)
70
+ end
71
+
72
+ def initialize(path:, buffer:)
73
+ @path = path
74
+ @buffer = buffer
75
+ @ruby_lines = nil
76
+ end
77
+
78
+ # @param buffer_line [Integer] 1-based, counting into the synthesized buffer.
79
+ # @param spelling [String, nil] the annotation's own text (`"%a{pure}"`); read off the buffer line
80
+ # when the caller does not already hold it.
81
+ # @return [Integer] the 1-based line of the same annotation in the Ruby file.
82
+ def line_for(buffer_line, spelling: nil)
83
+ spelling = normalize(spelling) || spelling_at(buffer_line)
84
+ return buffer_line if spelling.nil?
85
+
86
+ candidates = ruby_lines.filter_map { |number, text| number if text.include?(spelling) }
87
+ return buffer_line if candidates.empty?
88
+
89
+ candidates[ordinal_of(buffer_line, spelling)] || candidates.first
90
+ end
91
+
92
+ private
93
+
94
+ # The spelling as it appears in both documents. Callers hold it as `"%a{#{annotation.string}}"`,
95
+ # which is already that form; anything else is read for its annotation text or dropped.
96
+ def normalize(spelling)
97
+ spelling.to_s[SPELLING_PATTERN]
98
+ end
99
+
100
+ def spelling_at(buffer_line)
101
+ @buffer.each_line.with_index(1) do |text, number|
102
+ return text[SPELLING_PATTERN] if number == buffer_line
103
+ end
104
+ nil
105
+ end
106
+
107
+ # How many annotations of the same spelling the buffer declares before `buffer_line`. Comment
108
+ # lines are skipped: the writer echoes the author's `# @rbs %a{…}` above the annotation it
109
+ # generates, and counting both would land every lookup one match too far down the file.
110
+ def ordinal_of(buffer_line, spelling)
111
+ seen = 0
112
+ @buffer.each_line.with_index(1) do |text, number|
113
+ break if number >= buffer_line
114
+
115
+ stripped = text.lstrip
116
+ seen += 1 if stripped.start_with?(ANNOTATION_PREFIX) && stripped.include?(spelling)
117
+ end
118
+ seen
119
+ end
120
+
121
+ # The Ruby file's comment lines, as `[line number, text]`. Read once, and only for a position that
122
+ # is already going to be rendered.
123
+ def ruby_lines
124
+ @ruby_lines ||= begin
125
+ File.foreach(@path).with_index(1).filter_map do |text, number|
126
+ [number, text] if text.lstrip.start_with?(COMMENT_PREFIX)
127
+ end
128
+ rescue StandardError
129
+ [].freeze
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # The effect-label grammar and the subsumption relation over it (ADR-103 WD1; normative in
6
+ # `docs/type-specification/effect-labels.md`).
7
+ #
8
+ # A label is a dot-path of lowercase segments — `io`, `io.net.http`, `nondet.time`. The relation
9
+ # that matters is **segment-aware prefix subsumption**: `io` admits `io.net.http` and rejects
10
+ # `iota`. Every method here is pure and total; a malformed input is answered, never raised on,
11
+ # so a caller can ask `valid?` and the rest in either order.
12
+ module Label
13
+ # `label = segment { "." segment }`, `segment = [a-z][a-z0-9]*`. Deliberately narrow: no
14
+ # underscores, no hyphens, no uppercase, no empty segment, no trailing dot. The same grammar
15
+ # is the RFC's `label` production, so a label spells identically in Steins and Rigor.
16
+ PATTERN = /\A[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*\z/
17
+
18
+ SEPARATOR = "."
19
+ private_constant :SEPARATOR
20
+
21
+ module_function
22
+
23
+ # Whether `str` is a well-formed label. Anything that is not a String is not.
24
+ def valid?(str)
25
+ str.is_a?(String) && PATTERN.match?(str)
26
+ end
27
+
28
+ # The label's segments, outermost first: `"io.net.http"` -> `["io", "net", "http"]`.
29
+ # A malformed label yields an empty array rather than a partial parse.
30
+ def segments(label)
31
+ return [].freeze unless valid?(label)
32
+
33
+ label.split(SEPARATOR).freeze
34
+ end
35
+
36
+ # Whether `bound` admits `label` under segment-aware prefix subsumption. A label subsumes
37
+ # itself; `io` subsumes `io.net.http`; `io` does NOT subsume `iota`, because the match is on
38
+ # segment boundaries and not on characters.
39
+ def subsumes?(bound, label)
40
+ return false unless valid?(bound) && valid?(label)
41
+ return true if bound == label
42
+
43
+ label.start_with?("#{bound}#{SEPARATOR}")
44
+ end
45
+
46
+ # The label one segment shallower, or `nil` for a root (and for a malformed label).
47
+ def parent(label)
48
+ return nil unless valid?(label)
49
+
50
+ index = label.rindex(SEPARATOR)
51
+ return nil unless index
52
+
53
+ label[0, index]
54
+ end
55
+
56
+ # The label's proper ancestors, outermost first and excluding the label itself:
57
+ # `"io.net.http"` -> `["io", "io.net"]`. A root has no ancestors.
58
+ def ancestors(label)
59
+ parts = segments(label)
60
+ return [].freeze if parts.length <= 1
61
+
62
+ result = []
63
+ prefix = nil
64
+ parts[0...-1].each do |segment|
65
+ prefix = prefix ? "#{prefix}#{SEPARATOR}#{segment}" : segment
66
+ result << prefix
67
+ end
68
+ result.freeze
69
+ end
70
+
71
+ # The label's outermost segment — the root whose ownership the registry checks.
72
+ def root(label)
73
+ segments(label).first
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # Whether an unrecognised effect label is evidence of a **typo** rather than of a word that was
8
+ # never meant to be a label at all (ADR-103 WD1; normative in
9
+ # `docs/type-specification/effect-labels.md` § Unknown labels).
10
+ #
11
+ # The degradation an unknown label causes is silent by construction — the tag reads ⊤ and stops
12
+ # bounding anything — so a paired diagnostic is the only thing that keeps the fail-open rule
13
+ # honest. But the diagnostic cannot fire on every unrecognised spelling: a vocabulary is open by
14
+ # design (`effects.labels:`, a plugin's own root), so an unknown word is as likely to be a label
15
+ # this project has not registered yet as it is to be a misspelling. Reporting both would put a
16
+ # finding on correct-by-intent code, which is the direction the false-positive budget is not
17
+ # allowed to run ([ADR-5](../../../docs/adr/5-robustness-principle.md)).
18
+ #
19
+ # Intent is therefore read off four signals, any one of which is enough:
20
+ #
21
+ # 1. **A near miss** — the spelling is within {Registry::SUGGESTION_DISTANCE_CAP} edits of a
22
+ # label the registry knows (`io.bd` against `io.db`).
23
+ # 2. **A known sibling** — another member of the same comma-separated list is recognised, so the
24
+ # list as a whole is demonstrably written in this vocabulary.
25
+ # 3. **A dotted path** — the token carries two or more segments (`io.netw`). Nothing but a label
26
+ # is spelled that way; a project opening its own root writes a bare word first.
27
+ # 4. **A retired spelling** — the registry's `retired:` table names it, so the author wrote a
28
+ # label that WAS correct and a vocabulary bump moved it.
29
+ #
30
+ # A lone far-off word (`%a{rigor:v1:effect database}`) matches none of them and stays silent
31
+ # everywhere. It still degrades the tag to ⊤ — the reading never depends on this module.
32
+ module LabelIntent
33
+ # How many dot-separated segments make a token unmistakably label-shaped (signal 3).
34
+ MULTI_SEGMENT_ARITY = 2
35
+
36
+ module_function
37
+
38
+ # Whether reporting `token` as an unknown label is justified.
39
+ #
40
+ # @param token [String] the spelling as written.
41
+ # @param registry [Rigor::Effects::Registry, nil] the vocabulary AFTER plugin load; `nil` (no
42
+ # vocabulary at all) makes every token unjudgeable and therefore silent.
43
+ # @param siblings [Array<String>] the other tokens of the same list / the same config value.
44
+ # @return [Boolean]
45
+ def evident?(token, registry, siblings: [])
46
+ return false if registry.nil?
47
+ return false unless Label.valid?(token)
48
+ return false if registry.known?(token)
49
+
50
+ retired?(token, registry) || near_miss?(token, registry) ||
51
+ multi_segment?(token) || known_sibling?(token, registry, siblings)
52
+ end
53
+
54
+ def retired?(token, registry)
55
+ !Array(registry.retired(token)).empty?
56
+ end
57
+
58
+ def near_miss?(token, registry)
59
+ !registry.suggest(token).nil?
60
+ end
61
+
62
+ def multi_segment?(token)
63
+ Label.segments(token).length >= MULTI_SEGMENT_ARITY
64
+ end
65
+
66
+ def known_sibling?(token, registry, siblings)
67
+ Array(siblings).any? { |sibling| sibling != token && registry.known?(sibling.to_s) }
68
+ end
69
+
70
+ private_class_method :retired?, :near_miss?, :multi_segment?, :known_sibling?
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # An immutable, sorted, de-duplicated set of effect labels — the carrier of a summary lane and
8
+ # of an envelope's bound (ADR-103 WD1).
9
+ #
10
+ # Two sentinels bracket the lattice. {EMPTY} is the empty set: "no effects", the reading of
11
+ # `%a{pure}` modulo `mutate.local`. {TOP} is the unbounded / unspecified reading: it admits
12
+ # every label, it absorbs every join, and it is what a tag carrying an unknown label degrades
13
+ # to (the fail-open rule). {TOP} is NOT the set of all registered labels and is deliberately
14
+ # not enumerable — `top?` is the only way to tell it from {EMPTY} through `to_a`.
15
+ #
16
+ # Instances are frozen on construction and hold a frozen array, so they cross a Ractor boundary
17
+ # and go into a cache entry as they are.
18
+ class LabelSet
19
+ NO_LABELS = [].freeze
20
+ private_constant :NO_LABELS
21
+
22
+ # Build a set from any enumerable of label strings. Members are normalised (de-duplicated and
23
+ # sorted) so equality is structural and a snapshot rendering is deterministic.
24
+ #
25
+ # Raises `ArgumentError` on a member that does not satisfy {Label::PATTERN}: a LabelSet is a
26
+ # value object over the grammar, and the fail-open handling of an unrecognised *spelling* is
27
+ # the reader's job (it yields {TOP}), not this constructor's.
28
+ #
29
+ # `top:` is internal — it exists to build {TOP} and is not part of the surface later slices
30
+ # build on.
31
+ def initialize(labels = NO_LABELS, top: false)
32
+ @top = top
33
+ @labels = NO_LABELS
34
+ unless top
35
+ members = labels.to_a
36
+ members.each do |label|
37
+ raise ArgumentError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
38
+ end
39
+ @labels = members.uniq.sort.freeze
40
+ end
41
+ freeze
42
+ end
43
+
44
+ # The unbounded set. Everything is subsumed by it; joining it with anything yields it.
45
+ TOP = new(NO_LABELS, top: true)
46
+
47
+ # The empty set — no effects at all.
48
+ EMPTY = new(NO_LABELS)
49
+
50
+ # Whether this is the unbounded sentinel {TOP}.
51
+ def top?
52
+ @top
53
+ end
54
+
55
+ # Whether this set records no labels. {TOP} is not empty: it records nothing because it
56
+ # stands for everything.
57
+ def empty?
58
+ !@top && @labels.empty?
59
+ end
60
+
61
+ # The members, sorted, as a frozen array. {TOP} yields `[]` — consult {top?} first.
62
+ def to_a
63
+ @labels
64
+ end
65
+
66
+ # Exact membership among the recorded labels. `LabelSet.new(["io"]).include?("io.net")` is
67
+ # false — that question is {admits?}. {TOP} records no members, so it includes none.
68
+ def include?(label)
69
+ @labels.include?(label)
70
+ end
71
+
72
+ # Whether some member of this set subsumes `label`. {TOP} admits every well-formed label.
73
+ def admits?(label)
74
+ return Label.valid?(label) if @top
75
+
76
+ @labels.any? { |member| Label.subsumes?(member, label) }
77
+ end
78
+
79
+ # Union. {TOP} absorbs: a join involving it is {TOP}.
80
+ #
81
+ # A join that adds nothing returns `self` **without allocating**. That is the common case wherever
82
+ # sets are joined in a loop — the propagator's fixpoint re-joins every edge on every visit — and the
83
+ # sets are single-digit-sized, so the containment scan is cheaper than the construction it avoids.
84
+ def join(other)
85
+ return TOP if @top || other.top?
86
+ return other if @labels.empty?
87
+
88
+ others = other.to_a
89
+ return self if others.empty?
90
+ return self if others.all? { |label| @labels.include?(label) }
91
+
92
+ self.class.new(@labels + others)
93
+ end
94
+
95
+ # The members `other` does not already admit — the **declared lane's rendering rule**. A declared
96
+ # `io.net.http` beside a proven `io.net` says nothing the proven lane did not already say more
97
+ # strongly, so a renderer drops it rather than printing `[io.net] ≤ [io.net.http]`, which reads as
98
+ # two facts where there is one. Applied where output is produced, never to the table: the lanes
99
+ # themselves stay raw, because a later join has to see what was actually declared.
100
+ def excluding_subsumed_by(other)
101
+ return self if @top || other.empty?
102
+
103
+ kept = @labels.reject { |label| other.admits?(label) }
104
+ return self if kept.length == @labels.length
105
+
106
+ kept.empty? ? EMPTY : self.class.new(kept)
107
+ end
108
+
109
+ # Whether every member of this set is admitted by `bound_set` — the envelope check, modulo
110
+ # the policy discharge that happens at judgment time. {TOP} is bounded only by {TOP}.
111
+ def subsumed_by?(bound_set)
112
+ return true if bound_set.top?
113
+ return false if @top
114
+
115
+ @labels.all? { |label| bound_set.admits?(label) }
116
+ end
117
+
118
+ def ==(other)
119
+ return true if equal?(other)
120
+
121
+ other.is_a?(LabelSet) && other.top? == @top && other.to_a == @labels
122
+ end
123
+ alias eql? ==
124
+
125
+ def hash
126
+ [self.class, @top, @labels].hash
127
+ end
128
+
129
+ def inspect
130
+ return "#<Rigor::Effects::LabelSet TOP>" if @top
131
+
132
+ "#<Rigor::Effects::LabelSet #{@labels.join(', ')}>"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "envelope"
4
+ require_relative "envelope_check"
5
+ require_relative "method_key"
6
+ require_relative "path_finder"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # Judges an override against the envelope it inherits (ADR-103 WD1 / WD14; #386). The one place
11
+ # `effect.liskov-widened` is decided.
12
+ #
13
+ # An envelope is a contract about a method, and Ruby's `class PgRepo < Repo` says a `PgRepo` is
14
+ # usable wherever a `Repo` is. So a bound written on `Repo#find` binds `PgRepo#find` too:
15
+ # **implementations may be purer than the bound they inherit, never less pure.** That is Liskov
16
+ # inclusion applied to the second dimension, and it is what makes the declared lane's nominal carrier
17
+ # (#386, {EnvelopeIndex}) honest — a caller that imports `≤ io.db` from a `Repo`-typed receiver is
18
+ # entitled to that bound whichever subclass actually arrives.
19
+ #
20
+ # Two comparisons, and an override is subject to exactly one of them:
21
+ #
22
+ # - **Proven against the inherited bound** — the override declares nothing of its own, so what it
23
+ # *does* is what the ancestor's bound has to admit. It reads the same lane
24
+ # {EnvelopeCheck} reads, for the same reasons: the proven closure, undischarged per policy,
25
+ # `mutate.local` tolerated, taint ignored.
26
+ # - **Declared against the inherited bound** — the override declares its own envelope, and a bound
27
+ # wider than the one it inherits is a Liskov violation in the *declaration*, before any body is
28
+ # consulted. Two authored bounds compared by subsumption; nothing proven enters it.
29
+ #
30
+ # The split is exclusive on purpose. An override that declares its own envelope is already held to
31
+ # that envelope by `effect.envelope-exceeded`, so running the proven comparison too would put two
32
+ # diagnostics on one line for one label. What the author asserted is the thing Liskov has to judge;
33
+ # whether the body honours the assertion is the other rule's question.
34
+ #
35
+ # Both-sides-authored, in the [ADR-35](../../../docs/adr/35-override-signature-compatibility.md)
36
+ # sense: nothing fires unless an author wrote an envelope on the ancestor. That is the accepted
37
+ # construction the false-positive budget is spent under — a firing is never unsolicited.
38
+ #
39
+ # **Nominal subclassing only.** An included module's method is not an override in this slice: Ruby's
40
+ # ancestry puts an includer's own `def` *ahead* of the module's rather than under it, and the
41
+ # substitutability argument that licenses the check is the subclass one. The `superclasses` table is
42
+ # the collector's own, as-written and resolved here exactly as {Propagator} resolves it, so the
43
+ # relation the check reads and the closed world the proven lane travels can never disagree.
44
+ module LiskovCheck
45
+ # One (override, exceeding label) pair.
46
+ #
47
+ # `own_envelope` is the override's own bound when there is one, which is also what selects the
48
+ # message variant: present means the declaration-level comparison produced this finding, nil means
49
+ # the proven one. `chain` / `origin` explain the proven variant and are nil for the other, which
50
+ # has no path to walk — a declaration is not proved by anything.
51
+ Finding = Data.define(:key, :label, :ancestor_key, :ancestor_envelope, :own_envelope, :path, :line,
52
+ :chain, :origin)
53
+
54
+ NO_FINDINGS = [].freeze
55
+ private_constant :NO_FINDINGS
56
+
57
+ module_function
58
+
59
+ # @param table [EffectTable] the run's propagated graph.
60
+ # @param superclasses [Hash{String => Array<String>}] the collector's as-written superclass
61
+ # candidate lists (`FileCollection#superclasses`).
62
+ # @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
63
+ # @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
64
+ # @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
65
+ # to the classes they select.
66
+ # @param positions [EnvelopeCheck::Positions] where the override's `def` is.
67
+ # @param apply_tolerated [Boolean] false judges against `proven` — `--no-tolerated-effects`.
68
+ # @return [Array<Finding>] sorted by position then key then label.
69
+ def run(table:, superclasses:, method_envelopes:, class_envelopes:, config_envelopes: {},
70
+ positions: EnvelopeCheck::Positions.empty, apply_tolerated: true)
71
+ # The distributed strata, over a base of the raw per-method annotations: a base class whose method
72
+ # exists only in `.rbs` — an abstract `def find: (Integer) -> User` with no Ruby body — has no
73
+ # key in the table and so no distributed entry, and its bound is exactly the one an override
74
+ # inherits. Distribution wins on collision, which is the same value for a key that has both.
75
+ envelopes = method_envelopes.merge(
76
+ EnvelopeCheck.distribute(table, method_envelopes, class_envelopes, config_envelopes)
77
+ )
78
+ return NO_FINDINGS if envelopes.empty?
79
+
80
+ parents = parent_map(table, superclasses)
81
+ return NO_FINDINGS if parents.empty?
82
+
83
+ findings = []
84
+ keys = table.keys
85
+ keys.each { |key| collect(findings, table, key, envelopes, parents, positions, apply_tolerated) }
86
+ findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
87
+ end
88
+
89
+ # `{child class => parent class}`, one parent per child.
90
+ #
91
+ # An ancestry name is recorded as written, so `class Loud < Base` inside `module Tracer` arrives as
92
+ # the candidate list Ruby's own lexical lookup would try, most-qualified first. The most-qualified
93
+ # candidate the project actually defines wins — the same rule {Propagator::Index#build_descendants}
94
+ # applies, and for the same reason: without it `A::Base` and `B::Base` would share the bare spelling
95
+ # `Base` and an unrelated class's envelope would bind an override that never inherited it.
96
+ def parent_map(table, superclasses)
97
+ return {} if superclasses.nil? || superclasses.empty?
98
+
99
+ known = table.keys.filter_map { |key| MethodKey.owner(key) }.to_set
100
+ superclasses.each_with_object({}) do |(child, candidates), out|
101
+ parent = Array(candidates).find { |candidate| known.include?(candidate) }
102
+ out[child] = parent if parent && parent != child
103
+ end
104
+ end
105
+
106
+ def collect(findings, table, key, envelopes, parents, positions, apply_tolerated)
107
+ class_name, separator, selector = MethodKey.split(key)
108
+ return if class_name.nil?
109
+
110
+ ancestor_key = inherited_key(class_name, separator, selector, envelopes, parents)
111
+ return if ancestor_key.nil?
112
+
113
+ inherited = envelopes.fetch(ancestor_key)
114
+ own = envelopes[key]
115
+ position = positions.for(key)
116
+ if own && !own.top?
117
+ collect_declared(findings, key, ancestor_key, inherited, own, position)
118
+ else
119
+ collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
120
+ end
121
+ end
122
+
123
+ # The nearest ancestor whose own method key carries an envelope. Nearest wins for the same reason
124
+ # it does among strata: a bound written closer to the override is the more specific statement about
125
+ # it, and a grandparent's bound is already binding on the parent that sits between them.
126
+ def inherited_key(class_name, separator, selector, envelopes, parents)
127
+ seen = Set.new([class_name])
128
+ current = parents[class_name]
129
+ while current && seen.add?(current)
130
+ candidate = "#{current}#{separator}#{selector}"
131
+ return candidate if envelopes.key?(candidate) && !envelopes.fetch(candidate).top?
132
+
133
+ current = parents[current]
134
+ end
135
+ nil
136
+ end
137
+
138
+ def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
139
+ entry = table[key]
140
+ return if entry.nil?
141
+
142
+ exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
143
+ exceeding.each do |label|
144
+ trail = PathFinder.shortest(table, symbol: key, label: label)
145
+ findings << Finding.new(
146
+ key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
147
+ own_envelope: nil, path: position.first, line: position.last,
148
+ chain: trail&.chain || [key].freeze, origin: trail&.origin
149
+ )
150
+ end
151
+ end
152
+
153
+ # Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
154
+ # everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
155
+ def collect_declared(findings, key, ancestor_key, inherited, own, position)
156
+ own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
157
+ findings << Finding.new(
158
+ key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
159
+ own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
160
+ )
161
+ end
162
+ end
163
+
164
+ private_class_method :parent_map, :collect, :inherited_key, :collect_proven, :collect_declared
165
+ end
166
+ end
167
+ end