rigortype 0.3.4 → 0.3.6

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
  6. data/docs/handbook/02-everyday-types.md +1 -1
  7. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  8. data/docs/handbook/08-understanding-errors.md +1 -1
  9. data/docs/handbook/09-plugins.md +2 -2
  10. data/docs/handbook/10-sorbet.md +1 -1
  11. data/docs/handbook/README.md +2 -2
  12. data/docs/handbook/appendix-go.md +1 -1
  13. data/docs/handbook/appendix-java-csharp.md +2 -2
  14. data/docs/handbook/appendix-mypy.md +1 -1
  15. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  16. data/docs/handbook/appendix-rust.md +1 -1
  17. data/docs/handbook/appendix-type-theory.md +2 -2
  18. data/docs/handbook/appendix-typescript.md +4 -4
  19. data/docs/manual/02-cli-reference.md +73 -22
  20. data/docs/manual/03-configuration.md +19 -2
  21. data/docs/manual/04-diagnostics.md +15 -2
  22. data/docs/manual/08-skills.md +1 -1
  23. data/docs/manual/11-ci.md +38 -1
  24. data/docs/manual/12-caching.md +40 -1
  25. data/docs/manual/15-type-protection-coverage.md +1 -1
  26. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  27. data/docs/manual/18-removing-dead-code.md +13 -4
  28. data/docs/manual/19-effect-labels.md +678 -0
  29. data/docs/manual/README.md +7 -2
  30. data/docs/manual/ci-templates/README.md +9 -0
  31. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  32. data/docs/manual/plugins/rigor-activejob.md +3 -3
  33. data/docs/manual/plugins/rigor-activerecord.md +4 -4
  34. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  35. data/docs/manual/plugins/rigor-rspec.md +6 -2
  36. data/lib/rigor/analysis/diagnostic.rb +17 -6
  37. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  38. data/lib/rigor/analysis/reachability/graph.rb +30 -11
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  40. data/lib/rigor/analysis/reachability/scan.rb +20 -4
  41. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  42. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  43. data/lib/rigor/analysis/run_cache_key.rb +12 -0
  44. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  45. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
  48. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  49. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  50. data/lib/rigor/analysis/runner.rb +163 -31
  51. data/lib/rigor/analysis/worker_session.rb +3 -1
  52. data/lib/rigor/cache/file_digest.rb +20 -2
  53. data/lib/rigor/cli/check_command.rb +67 -48
  54. data/lib/rigor/cli/coverage_command.rb +5 -6
  55. data/lib/rigor/cli/doc_links.rb +100 -0
  56. data/lib/rigor/cli/docs_command.rb +32 -2
  57. data/lib/rigor/cli/effects_command.rb +149 -10
  58. data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
  59. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  60. data/lib/rigor/cli/effects_renderer.rb +41 -3
  61. data/lib/rigor/cli/effects_report.rb +116 -6
  62. data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
  63. data/lib/rigor/cli/unused_command.rb +65 -16
  64. data/lib/rigor/cli.rb +33 -9
  65. data/lib/rigor/configuration.rb +37 -25
  66. data/lib/rigor/configuration_error.rb +20 -0
  67. data/lib/rigor/effects/collector.rb +38 -1
  68. data/lib/rigor/effects/definition_lines.rb +100 -0
  69. data/lib/rigor/effects/entry_points.rb +47 -1
  70. data/lib/rigor/effects/envelope_check.rb +18 -1
  71. data/lib/rigor/effects/file_collection.rb +18 -4
  72. data/lib/rigor/effects/framework_units.rb +68 -13
  73. data/lib/rigor/effects/inline_anchor.rb +134 -0
  74. data/lib/rigor/effects/liskov_check.rb +17 -8
  75. data/lib/rigor/effects/plugin_facts.rb +62 -13
  76. data/lib/rigor/effects/propagator.rb +79 -19
  77. data/lib/rigor/effects/registry.rb +10 -3
  78. data/lib/rigor/effects/scanner.rb +21 -9
  79. data/lib/rigor/effects/signature_sources.rb +29 -2
  80. data/lib/rigor/effects/snapshot.rb +74 -26
  81. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  82. data/lib/rigor/effects/taint_cause.rb +1 -0
  83. data/lib/rigor/effects/unit_scan.rb +87 -12
  84. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  85. data/lib/rigor/plugin/base.rb +4 -0
  86. data/lib/rigor/plugin/box.rb +18 -2
  87. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  88. data/lib/rigor/plugin/manifest.rb +34 -10
  89. data/lib/rigor/plugin/registry.rb +10 -3
  90. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  91. data/lib/rigor/rbs_extended.rb +22 -2
  92. data/lib/rigor/version.rb +1 -1
  93. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  94. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  95. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  96. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  97. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  98. data/skills/rigor-ci-setup/SKILL.md +2 -2
  99. data/skills/rigor-editor-setup/SKILL.md +2 -2
  100. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  101. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  102. data/skills/rigor-plugin-review/SKILL.md +3 -3
  103. metadata +10 -1
@@ -82,12 +82,15 @@ module Rigor
82
82
  # arguments, as the scanner harvested them
83
83
  # @param uniqueness [Boolean] whether the class body declares a uniqueness validator
84
84
  # @param plugin_facts [PluginFacts]
85
- def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:)
85
+ # @param own_units [Hash{String=>Boolean}] the units the class body itself defines, keyed by the
86
+ # suffix a synthetic key carries (`"#save"`, `".create"`), each mapped to whether that body
87
+ # reaches `super`. Read by {.framework_row} and by nothing else.
88
+ def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:, own_units: {})
86
89
  units = []
87
90
  plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
88
91
  next unless plugin_facts.descends_from?(class_name, edge.receiver)
89
92
 
90
- units.concat(active_record_units(class_name, macros, uniqueness))
93
+ units.concat(active_record_units(class_name, macros, uniqueness, plugin_facts, own_units))
91
94
  end
92
95
  plugin_facts.edges_for(:perform_now).each do |edge|
93
96
  next unless plugin_facts.descends_from?(class_name, edge.receiver)
@@ -110,17 +113,18 @@ module Rigor
110
113
  # `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
111
114
  # no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
112
115
  # every model in the project and say nothing.
113
- def active_record_units(class_name, macros, uniqueness)
116
+ def active_record_units(class_name, macros, uniqueness, plugin_facts, own_units)
114
117
  validation = callbacks(macros, VALIDATION_MACROS)
115
118
  save = validation + callbacks(macros, SAVE_MACROS)
116
119
  destroy = callbacks(macros, DESTROY_MACROS)
117
120
  read = uniqueness ? uniqueness_summary(class_name) : nil
121
+ context = { plugin_facts: plugin_facts, own_units: own_units }
118
122
 
119
123
  units = []
120
- units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false))
121
- units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true))
122
- units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false))
123
- units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false))
124
+ units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false, **context))
125
+ units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true, **context))
126
+ units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false, **context))
127
+ units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false, **context))
124
128
  units
125
129
  end
126
130
 
@@ -132,11 +136,61 @@ module Rigor
132
136
  end
133
137
  end
134
138
 
135
- def triggers(class_name, selectors, targets, read, singleton:)
139
+ # A synthesised trigger exists only when the class body earned it — see {.active_record_units} — but
140
+ # once it exists it stands for the whole of `save`, so it carries the framework's own claim about the
141
+ # selector as well as the callbacks (#440).
142
+ def triggers(class_name, selectors, targets, read, singleton:, plugin_facts:, own_units:)
136
143
  return [] if targets.empty? && read.nil?
137
144
 
138
145
  edges = targets.map { |target| edge_to(class_name, target) }
139
- selectors.map { |selector| unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges, read) }
146
+ selectors.map do |selector|
147
+ row = framework_row(class_name, selector, singleton, plugin_facts, own_units)
148
+ unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges,
149
+ declared_bundles(read, row), causes(row))
150
+ end
151
+ end
152
+
153
+ # What the loaded plugins say `class_name`'s `selector` itself does — `ActiveRecord::Base#save` is
154
+ # `io.db.write` — read off the very table a call site reads (#440).
155
+ #
156
+ # Without it the synthetic unit carried the validator's `SELECT` and nothing else, so a model with a
157
+ # `before_save` or a uniqueness validator reported `AuthSource#save: ≤ io.db.read`: the write was
158
+ # attributed at every *call site* and never on the row that names the method, which is the row a
159
+ # reviewer reads. A `narrow:` row is skipped, because narrowing reads an argument at a call site and
160
+ # there is no call site here.
161
+ def framework_row(class_name, selector, singleton, plugin_facts, own_units)
162
+ return nil if replaced?(own_units, selector, singleton)
163
+
164
+ row = plugin_facts.class_row(class_name, singleton, selector)
165
+ return nil if row.nil? || row.narrow || row.labels.empty?
166
+
167
+ row
168
+ end
169
+
170
+ # Whether the class spelled the selector out itself and never reaches `super`. Such a body REPLACED
171
+ # the framework's implementation, so the framework's claim no longer describes what runs: a
172
+ # `def save = false` that persists nothing must keep reporting nothing. A body that does reach
173
+ # `super` keeps the claim, which is the case the exemption exists to not break.
174
+ def replaced?(own_units, selector, singleton)
175
+ key = "#{singleton ? '.' : '#'}#{selector}"
176
+ own_units.key?(key) && !own_units[key]
177
+ end
178
+
179
+ def declared_bundles(read, row)
180
+ bundles = read ? read.dup : {}
181
+ bundles[Origin.plugin(row.key)] = row.labels if row
182
+ bundles
183
+ end
184
+
185
+ # Mirrors {UnitScan#attribute_plugin}: a row may discharge and still taint, and a row from a plugin
186
+ # the engine does not bundle is a claim that leaves the unit non-exhaustive.
187
+ def causes(row)
188
+ return [] if row.nil?
189
+
190
+ list = []
191
+ list << [row.taint, row.key] if row.taint
192
+ list << ["plugin-attribution", row.key] unless row.discharge?
193
+ list
140
194
  end
141
195
 
142
196
  def callbacks(macros, names)
@@ -151,8 +205,9 @@ module Rigor
151
205
  { Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
152
206
  end
153
207
 
154
- def unit(key, edges, declared = nil)
155
- [key, Summary.new(declared_bundles: declared || {}), edges]
208
+ def unit(key, edges, declared = nil, causes = [])
209
+ summary = Summary.new(declared_bundles: declared || {}, exhaustive: causes.empty?, causes: causes)
210
+ [key, summary, edges]
156
211
  end
157
212
 
158
213
  def edge_to(class_name, selector)
@@ -160,8 +215,8 @@ module Rigor
160
215
  self_call: false)
161
216
  end
162
217
 
163
- private_class_method :active_record_units, :mailer_units, :triggers, :callbacks, :uniqueness_summary,
164
- :unit, :edge_to
218
+ private_class_method :active_record_units, :mailer_units, :triggers, :framework_row, :replaced?,
219
+ :declared_bundles, :causes, :callbacks, :uniqueness_summary, :unit, :edge_to
165
220
  end
166
221
  end
167
222
  end
@@ -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
@@ -112,11 +112,13 @@ module Rigor
112
112
 
113
113
  inherited = envelopes.fetch(ancestor_key)
114
114
  own = envelopes[key]
115
- position = positions.for(key)
115
+ # The position is read inside the two collectors, after they know a finding exists: `.for` is
116
+ # what forces a deferred position table, and an inherited envelope nothing widens must not
117
+ # cost a whole-project discovery parse.
116
118
  if own && !own.top?
117
- collect_declared(findings, key, ancestor_key, inherited, own, position)
119
+ collect_declared(findings, key, ancestor_key, inherited, own, positions)
118
120
  else
119
- collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
121
+ collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
120
122
  end
121
123
  end
122
124
 
@@ -135,16 +137,19 @@ module Rigor
135
137
  nil
136
138
  end
137
139
 
138
- def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
140
+ def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
139
141
  entry = table[key]
140
142
  return if entry.nil?
141
143
 
142
144
  exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
145
+ return if exceeding.empty?
146
+
147
+ path, line = positions.for(key)
143
148
  exceeding.each do |label|
144
149
  trail = PathFinder.shortest(table, symbol: key, label: label)
145
150
  findings << Finding.new(
146
151
  key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
147
- own_envelope: nil, path: position.first, line: position.last,
152
+ own_envelope: nil, path: path, line: line,
148
153
  chain: trail&.chain || [key].freeze, origin: trail&.origin
149
154
  )
150
155
  end
@@ -152,11 +157,15 @@ module Rigor
152
157
 
153
158
  # Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
154
159
  # 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|
160
+ def collect_declared(findings, key, ancestor_key, inherited, own, positions)
161
+ widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
162
+ return if widened.empty?
163
+
164
+ path, line = positions.for(key)
165
+ widened.each do |label|
157
166
  findings << Finding.new(
158
167
  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
168
+ own_envelope: own, path: path, line: line, chain: nil, origin: nil
160
169
  )
161
170
  end
162
171
  end
@@ -46,9 +46,16 @@ module Rigor
46
46
  # models are declared only in RBS gets no plugin attribution and no taint, which is the fail-quiet
47
47
  # direction.
48
48
  class PluginFacts
49
- # How far the superclass walk climbs before giving up. A project hierarchy deeper than this is either
50
- # pathological or cyclic, and a cycle is possible in a project's *as-written* table.
51
- ANCESTRY_CAP = 24
49
+ # How many ancestors the walk visits before giving up. It bounds *work*, not correctness: cycles are
50
+ # already handled by the visited set, and the walk is memoised per class.
51
+ #
52
+ # It was 24 while the walk climbed superclasses only, where a project deeper than that is
53
+ # pathological. Once included modules joined the walk (#456) the number stopped measuring depth and
54
+ # started measuring breadth, and Rails concerns make breadth ordinary — Mastodon's `Account` includes
55
+ # 21 modules, so `ActiveRecord::Base` fell off the end of a 24-entry chain and every `Account#save`
56
+ # silently lost its `io.db.write`. A cap that can be reached by ordinary code is a correctness bug
57
+ # wearing a budget's clothes.
58
+ ANCESTRY_CAP = 128
52
59
 
53
60
  NO_ROWS = {}.freeze
54
61
  private_constant :NO_ROWS
@@ -67,18 +74,21 @@ module Rigor
67
74
  Edge = Data.define(:target, :receiver, :selector, :plugin_id)
68
75
 
69
76
  def self.empty
70
- @empty ||= new(contributions: [], superclasses: NO_ROWS)
77
+ @empty ||= new(contributions: [], superclasses: NO_ROWS, includes: NO_ROWS)
71
78
  end
72
79
 
73
80
  # @param plugin_registry [Rigor::Plugin::Registry, nil]
74
81
  # @param superclasses [Hash{String=>String,Array<String>}] the project's as-written superclass table
75
82
  # (`Scope::DiscoveryIndex#discovered_superclasses`). Empty is legal and simply means no row matches
76
83
  # through inheritance.
77
- def self.build(plugin_registry, superclasses: NO_ROWS)
84
+ # @param includes [Hash{String=>Array<String>}] the project's as-written include / prepend table
85
+ # (`Scope::DiscoveryIndex#discovered_includes`), for the frameworks whose contract is a module
86
+ # rather than a base class (#456).
87
+ def self.build(plugin_registry, superclasses: NO_ROWS, includes: NO_ROWS)
78
88
  contributions = plugin_registry&.effect_contributions || []
79
89
  return empty if contributions.empty?
80
90
 
81
- new(contributions: contributions, superclasses: superclasses)
91
+ new(contributions: contributions, superclasses: superclasses, includes: includes)
82
92
  rescue StandardError
83
93
  empty
84
94
  end
@@ -94,7 +104,7 @@ module Rigor
94
104
  # Every `effect_entry_points:` preset across the loaded set, in registration order.
95
105
  attr_reader :entry_points
96
106
 
97
- def initialize(contributions:, superclasses:)
107
+ def initialize(contributions:, superclasses:, includes: NO_ROWS)
98
108
  @warnings = []
99
109
  @class_rows = {}
100
110
  @path_rows = {}
@@ -103,8 +113,10 @@ module Rigor
103
113
  @edges = []
104
114
  @labels_by_owner = {}
105
115
  @entry_points = []
116
+ @declared_ancestry = {}
106
117
  contributions.each { |contribution| absorb(contribution) }
107
118
  @superclasses = superclasses || NO_ROWS
119
+ @includes = includes || NO_ROWS
108
120
  @ancestry = {}
109
121
  @digest = compute_digest
110
122
  finalize
@@ -220,6 +232,27 @@ module Rigor
220
232
  plugin_id: contribution.id)
221
233
  end
222
234
  @entry_points.concat(contribution.entry_points)
235
+ contribution.ancestry.each { |entry| absorb_ancestry(contribution, entry) }
236
+ end
237
+
238
+ # ADR-103 WD17 (#465) — an ancestry edge the plugin's gem introduces, which the project's own
239
+ # source never writes.
240
+ #
241
+ # **Bundled plugins only**, and this is the one place it is enforced. A claim carries no labels of
242
+ # its own, which is what makes it look harmless: what it does is make *other* plugins' rows
243
+ # reachable, so a third-party plugin asserting `Foo < ActiveRecord::Base` would pull
244
+ # rigor-activerecord's first-party discharging rows onto `Foo`. The grant is the same fact
245
+ # {#discharge_granted?} reads — that the engine bundles this plugin — and the refusal is warned
246
+ # about rather than silent, because a claim that quietly did nothing would read to its author as
247
+ # the rows having vanished.
248
+ def absorb_ancestry(contribution, entry)
249
+ unless contribution.discharge_allowed
250
+ @warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
251
+ "#{entry.child} < #{entry.parent} ancestry claim is ignored"
252
+ return
253
+ end
254
+
255
+ (@declared_ancestry[entry.child] ||= []) << entry.parent
223
256
  end
224
257
 
225
258
  # ADR-103 WD2 — a plugin that asked to own a framework root and is not one the engine bundles keeps
@@ -259,16 +292,31 @@ module Rigor
259
292
  false
260
293
  end
261
294
 
262
- # `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the table is *as written*,
263
- # so a project can spell one that loops.
295
+ # `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the tables are *as
296
+ # written*, so a project can spell an ancestry that loops.
297
+ #
298
+ # Included and prepended modules are walked beside the superclass, nearer first, because that is
299
+ # Ruby's own lookup order and because a framework's contract is as often a module as a base class:
300
+ # a Sidekiq worker is `class TriggerWebhookWorker; include Sidekiq::Job`, with no base class at all,
301
+ # so a superclass-only walk could not match a row about it however the plugin spelled one (#456).
302
+ # `ExpressionTyper#enqueue_ancestors` already resolves a self-call this way; the two tables
303
+ # disagreeing about what an ancestor is was the bug.
264
304
  def ancestry(class_name)
265
305
  @ancestry[class_name] ||= begin
266
306
  chain = []
267
307
  seen = Set.new
268
- current = class_name
269
- while current && seen.add?(current) && chain.length < ANCESTRY_CAP
308
+ queue = [class_name]
309
+ until queue.empty? || chain.length >= ANCESTRY_CAP
310
+ current = queue.shift
311
+ next unless current && seen.add?(current)
312
+
270
313
  chain << current
271
- current = Array(@superclasses[current]).first
314
+ queue.concat(Array(@includes[current]).compact)
315
+ parent = Array(@superclasses[current]).first
316
+ queue << parent if parent
317
+ # Last, so a project's own declaration always wins the first-match race against a plugin's
318
+ # claim about a gem (#465).
319
+ queue.concat(Array(@declared_ancestry[current]))
272
320
  end
273
321
  chain.freeze
274
322
  end
@@ -281,7 +329,8 @@ module Rigor
281
329
  .map { |singleton| [singleton, sorted(@class_rows[singleton])] },
282
330
  sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
283
331
  @edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
284
- @entry_points.map(&:to_h).sort_by { |preset| preset["name"] }
332
+ @entry_points.map(&:to_h).sort_by { |preset| preset["name"] },
333
+ @declared_ancestry.sort.map { |child, parents| [child, parents.sort] }
285
334
  ]
286
335
  Digest::SHA256.hexdigest(payload.inspect)
287
336
  end
@@ -53,23 +53,49 @@ module Rigor
53
53
  return EffectTable.empty if collection.summaries.empty?
54
54
 
55
55
  summaries = collection.summaries
56
- edges = resolve_edges(collection)
57
56
  state = seed(summaries, discharge)
57
+ edges = resolve_edges(collection, state)
58
58
  iterate(state, edges)
59
59
  EffectTable.new(build_entries(summaries, edges, state))
60
60
  rescue StandardError
61
61
  EffectTable.empty
62
62
  end
63
63
 
64
- # `{caller_key => [callee_key]}`, sorted and de-duplicated.
65
- def resolve_edges(collection)
64
+ # `{caller_key => [callee_key]}`, sorted and de-duplicated. Seeds the `unresolved-super` taint in
65
+ # the same pass, because whether a `super` resolved is exactly what this resolution answers.
66
+ def resolve_edges(collection, state)
66
67
  index = Index.new(collection)
67
68
  collection.edges.each_with_object({}) do |(caller_key, list), out|
68
- targets = list.flat_map { |edge| index.targets_for(edge) }.uniq.sort
69
+ targets = list.flat_map do |edge|
70
+ resolved = index.targets_for(edge)
71
+ taint_unresolved_super(state, caller_key, edge) if edge.super_call && resolved.empty?
72
+ resolved
73
+ end.uniq.sort
69
74
  out[caller_key] = targets.freeze unless targets.empty?
70
75
  end
71
76
  end
72
77
 
78
+ # A `super` the project's own ancestry does not answer — the implementation is in a gem, in Ruby's
79
+ # core, or in a module prepended at run time — is the case the taint exists for (#446). Silence
80
+ # would be the one thing the effect model must never say: that the list is complete when a call in
81
+ # the body was not read. An unresolved ORDINARY edge is dropped instead, because most such calls
82
+ # are inherited ones the catalogue simply has no row for and the site's own taint was already
83
+ # decided from the typer's verdict; a `super` has no site verdict to fall back on, and it always
84
+ # dispatches to something.
85
+ #
86
+ # Decided here rather than in the collector because only the merged ancestry can say whether the
87
+ # parent resolves, and written into the SEED rather than into the direct summary so the fixpoint
88
+ # carries it to this method's callers exactly as it carries any other cause. A snapshot's
89
+ # `methods:` table records direct summaries and so does not show it; `reach:`, the report and the
90
+ # judgment all read the closure and do.
91
+ def taint_unresolved_super(state, caller_key, edge)
92
+ entry = state[caller_key]
93
+ return if entry.nil?
94
+
95
+ entry[:exhaustive] = false
96
+ entry[:causes] << ["unresolved-super", edge.selector].freeze
97
+ end
98
+
73
99
  # Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
74
100
  # {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
75
101
  # size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
@@ -170,8 +196,8 @@ module Rigor
170
196
  end
171
197
  end
172
198
 
173
- private_class_method :resolve_edges, :seed, :iterate, :reverse_edges, :absorb, :join_lane,
174
- :build_entries
199
+ private_class_method :resolve_edges, :taint_unresolved_super, :seed, :iterate, :reverse_edges,
200
+ :absorb, :join_lane, :build_entries
175
201
 
176
202
  # The class graph a run's collections describe, and the edge resolution over it. Built once per
177
203
  # propagation; every lookup is a Hash read.
@@ -189,25 +215,43 @@ module Rigor
189
215
  # Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
190
216
  # override of the same selector in a project subclass of the receiver's class.
191
217
  #
192
- # Memoised on `(receiver class, kind, selector)` — the answer depends on nothing else, and one
193
- # such triple is asked for once per call site in the project. `ApplicationRecord#save` alone is
218
+ # Memoised on `(receiver class, kind, selector, super?)` — the answer depends on nothing else, and
219
+ # one such tuple is asked for once per call site in the project. `ApplicationRecord#save` alone is
194
220
  # thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
195
221
  def targets_for(edge)
196
- @targets[[edge.receiver_class, edge.kind, edge.selector]] ||= begin
222
+ @targets[[edge.receiver_class, edge.kind, edge.selector, edge.super_call]] ||= begin
197
223
  separator = edge.kind == :singleton ? "." : "#"
198
- targets = []
199
- owner = resolve_owner(edge.receiver_class, separator, edge.selector)
200
- targets << owner if owner
201
- descendant_closure(edge.receiver_class).each do |subclass|
202
- key = "#{subclass}#{separator}#{edge.selector}"
203
- targets << key if @summaries.key?(key)
204
- end
205
- targets.freeze
224
+ edge.super_call ? super_targets(edge, separator) : call_targets(edge, separator)
206
225
  end
207
226
  end
208
227
 
209
228
  private
210
229
 
230
+ def call_targets(edge, separator)
231
+ targets = []
232
+ owner = resolve_owner(edge.receiver_class, separator, edge.selector)
233
+ targets << owner if owner
234
+ descendant_closure(edge.receiver_class).each do |subclass|
235
+ key = "#{subclass}#{separator}#{edge.selector}"
236
+ targets << key if @summaries.key?(key)
237
+ end
238
+ targets.freeze
239
+ end
240
+
241
+ # A `super` reaches **one** definition, and the closed-world override join every other edge gets
242
+ # is deliberately absent from it (#446). `super` in `C#m` dispatches into the ancestry above `C`
243
+ # in the receiver's chain, and a subclass of `C` is never in it however the receiver was
244
+ # constructed — so joining `D#m` would put a proven label on `C#m` that no execution of `C#m` can
245
+ # produce. Ruby's lack of `final` is the argument for the join at an ordinary call site and says
246
+ # nothing here.
247
+ def super_targets(edge, separator)
248
+ target = resolve_super(edge.receiver_class, separator, edge.selector)
249
+ target ? [target].freeze : NO_TARGETS
250
+ end
251
+
252
+ NO_TARGETS = [].freeze
253
+ private_constant :NO_TARGETS
254
+
211
255
  # The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
212
256
  # once, not once per selector reaching it.
213
257
  def descendant_closure(class_name)
@@ -220,8 +264,24 @@ module Rigor
220
264
  # enqueued and the most-qualified one comes first, so the right constant wins the race and a
221
265
  # spelling that names nothing simply matches no key.
222
266
  def resolve_owner(class_name, separator, selector)
223
- seen = Set.new
224
- queue = [class_name]
267
+ walk_ancestors([class_name], Set.new, separator, selector)
268
+ end
269
+
270
+ # Where `super` from `class_name#selector` lands: the same ancestor walk, started one step up —
271
+ # the modules the class includes, then its superclass chain — with the class itself already in
272
+ # `seen`, because a method never `super`s into itself.
273
+ #
274
+ # The includes are instance-side only. `include M` puts `M#m` between the class and its
275
+ # superclass, which is exactly where `super` looks, while `M.m` is a singleton method `include`
276
+ # never contributes; a singleton `super` that really does reach a module went through `extend` or
277
+ # a `class << self` include, neither of which is collected, so it resolves to nothing and taints.
278
+ def resolve_super(class_name, separator, selector)
279
+ queue = separator == "#" ? @includes.fetch(class_name, []).dup : []
280
+ queue.concat(@superclasses.fetch(class_name, []))
281
+ walk_ancestors(queue, Set.new([class_name]), separator, selector)
282
+ end
283
+
284
+ def walk_ancestors(queue, seen, separator, selector)
225
285
  until queue.empty?
226
286
  current = queue.shift
227
287
  next if current.nil? || !seen.add?(current)
@@ -74,14 +74,16 @@ module Rigor
74
74
  new(
75
75
  vocabulary_version: raw.fetch("vocabulary", 0),
76
76
  labels: raw.fetch("labels", nil) || [],
77
- retired: raw.fetch("retired", nil) || {}
77
+ retired: raw.fetch("retired", nil) || {},
78
+ descriptions: raw.fetch("descriptions", nil) || {}
78
79
  )
79
80
  end
80
81
 
81
82
  attr_reader :vocabulary_version
82
83
 
83
- def initialize(vocabulary_version:, labels:, retired: {})
84
+ def initialize(vocabulary_version:, labels:, retired: {}, descriptions: {})
84
85
  @vocabulary_version = vocabulary_version
86
+ @descriptions = descriptions.to_h { |root, text| [root.to_s, text.to_s.gsub(/\s+/, " ").strip] }.freeze
85
87
  @labels = labels.map(&:to_s).uniq.sort.freeze
86
88
  @known = build_known(@labels)
87
89
  @roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
@@ -96,6 +98,10 @@ module Rigor
96
98
  # The roots of the vocabulary — the outermost segments {#with} treats as already owned.
97
99
  attr_reader :roots
98
100
 
101
+ # One line per root, from the data file's `descriptions:` — what `rigor effects --list-labels`
102
+ # prints beside a root (#429). A root nothing describes (a plugin's, a project's) has no entry.
103
+ attr_reader :descriptions
104
+
99
105
  # Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
100
106
  # `io` is recognised because `io.net` exists, so a bound may name an interior node the data
101
107
  # file never spells out on its own line.
@@ -142,7 +148,8 @@ module Rigor
142
148
 
143
149
  check_ownership(label, owner)
144
150
  end
145
- self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired)
151
+ self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired,
152
+ descriptions: @descriptions)
146
153
  end
147
154
 
148
155
  private