rigortype 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/effects/core.yml +18 -1
  4. data/data/effects/registry.yml +31 -3
  5. data/docs/manual/02-cli-reference.md +60 -13
  6. data/docs/manual/03-configuration.md +20 -3
  7. data/docs/manual/04-diagnostics.md +3 -2
  8. data/docs/manual/11-ci.md +37 -0
  9. data/docs/manual/12-caching.md +39 -0
  10. data/docs/manual/16-rbs-extended-annotations.md +15 -2
  11. data/docs/manual/19-effect-labels.md +671 -0
  12. data/docs/manual/README.md +5 -0
  13. data/docs/manual/ci-templates/README.md +9 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +10 -3
  15. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  16. data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
  17. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
  18. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  19. data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
  20. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  21. data/lib/rigor/analysis/runner.rb +12 -3
  22. data/lib/rigor/analysis/worker_session.rb +3 -1
  23. data/lib/rigor/cli/effects_command.rb +123 -9
  24. data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
  25. data/lib/rigor/cli/effects_renderer.rb +41 -3
  26. data/lib/rigor/cli/effects_report.rb +116 -6
  27. data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
  28. data/lib/rigor/cli.rb +12 -1
  29. data/lib/rigor/configuration.rb +37 -25
  30. data/lib/rigor/configuration_error.rb +20 -0
  31. data/lib/rigor/effects/collector.rb +38 -1
  32. data/lib/rigor/effects/entry_points.rb +47 -1
  33. data/lib/rigor/effects/file_collection.rb +18 -4
  34. data/lib/rigor/effects/framework_units.rb +68 -13
  35. data/lib/rigor/effects/inline_anchor.rb +134 -0
  36. data/lib/rigor/effects/plugin_facts.rb +62 -13
  37. data/lib/rigor/effects/propagator.rb +79 -19
  38. data/lib/rigor/effects/registry.rb +10 -3
  39. data/lib/rigor/effects/scanner.rb +21 -9
  40. data/lib/rigor/effects/signature_sources.rb +16 -0
  41. data/lib/rigor/effects/snapshot.rb +21 -5
  42. data/lib/rigor/effects/taint_cause.rb +1 -0
  43. data/lib/rigor/effects/unit_scan.rb +87 -12
  44. data/lib/rigor/plugin/base.rb +4 -0
  45. data/lib/rigor/plugin/box.rb +18 -2
  46. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  47. data/lib/rigor/plugin/manifest.rb +34 -10
  48. data/lib/rigor/plugin/registry.rb +10 -3
  49. data/lib/rigor/rbs_extended.rb +22 -2
  50. data/lib/rigor/version.rb +1 -1
  51. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
  52. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  53. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  54. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  55. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
  56. metadata +6 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../configuration_error"
4
+
3
5
  module Rigor
4
6
  module Effects
5
7
  # The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
@@ -30,9 +32,19 @@ module Rigor
30
32
  # than becoming an unfindable key.
31
33
  NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
32
34
 
35
+ # A plugin's registration is wrong: a malformed preset name, or two plugins claiming one name with
36
+ # different globs. Nothing the user wrote, so it never reaches them as a configuration error —
37
+ # {PluginFacts} downgrades it to a warning.
33
38
  class Error < StandardError
34
39
  end
35
40
 
41
+ # The user's `effects.snapshot.reach:` names a preset no active plugin registered — the most common
42
+ # of these being chapter 03's `reach: [rails]` without `rigor-railties` in `plugins:` (#433). A
43
+ # {Rigor::ConfigurationError}, not an {Error}, because the offending text is in `.rigor.yml`: it is
44
+ # the CLI's to render as a `rigor:` line, not a crash.
45
+ class UnknownPreset < Rigor::ConfigurationError
46
+ end
47
+
36
48
  @presets = {}
37
49
 
38
50
  class << self
@@ -73,7 +85,8 @@ module Rigor
73
85
  Array(presets).each { |preset| register(preset.name, preset.globs) }
74
86
  end
75
87
 
76
- # Every registered preset name, sorted. Empty in this slice.
88
+ # Every registered preset name, sorted. Empty until a plugin declaring `effect_entry_points:`
89
+ # loads, which is what makes {availability} worth printing.
77
90
  def names
78
91
  @presets.keys.sort.freeze
79
92
  end
@@ -83,6 +96,39 @@ module Rigor
83
96
  @presets[name.to_s]
84
97
  end
85
98
 
99
+ # The globs `name` stands for, or the configuration error that says what this project could have
100
+ # written instead.
101
+ #
102
+ # The existence check lives here and runs from the snapshot build rather than from
103
+ # `Configuration` — presets are registered by plugins, and the plugins load FROM the configuration
104
+ # being validated, so at load time the registry is still empty. See {name?} for the shape check
105
+ # that does run at load.
106
+ def resolve!(name)
107
+ globs_for(name) || raise(
108
+ UnknownPreset,
109
+ "effects.snapshot.reach names no registered entry-point preset: #{name.to_s.inspect} " \
110
+ "(#{availability})"
111
+ )
112
+ end
113
+
114
+ # What this project may write in `effects.snapshot.reach:`, as one parenthetical clause.
115
+ #
116
+ # Single-homed here because the answer is a property of the loaded plugin set rather than of any
117
+ # one message: the unregistered-preset error (#433) and the note `rigor effects update` prints
118
+ # when `reach:` is empty (#436) have to give the same answer, and neither can hard-code a list
119
+ # that moves with `plugins:`.
120
+ #
121
+ # @return [String]
122
+ def availability
123
+ if @presets.empty?
124
+ return "no plugin in this project registers an entry-point preset — a preset is named by the " \
125
+ "plugin that models the framework, so listing that plugin under `plugins:` is what " \
126
+ "registers one"
127
+ end
128
+
129
+ "presets registered in this project: #{names.join(', ')}"
130
+ end
131
+
86
132
  # Drops every registration. For specs only — production code registers and never unregisters.
87
133
  def reset!
88
134
  @presets = {}
@@ -30,7 +30,16 @@ module Rigor
30
30
  # receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
31
31
  # `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
32
32
  # whose failure to resolve is an `unresolved-self-call` taint rather than silence.
33
- Edge = Data.define(:receiver_class, :kind, :selector, :self_call)
33
+ #
34
+ # `super_call` marks the edge a `super` contributes (#446). It carries the ENCLOSING unit's class and
35
+ # selector rather than a receiver's, and the propagator resolves it against the ancestry *above* that
36
+ # class with no closed-world override join — a different question from every other edge, which is why
37
+ # it is a field rather than a convention over the other three.
38
+ Edge = Data.define(:receiver_class, :kind, :selector, :self_call, :super_call) do
39
+ # Defaulted because every producer but the `super` one records an ordinary call, and an ordinary
40
+ # call is not a `super`.
41
+ def initialize(super_call: false, **) = super
42
+ end
34
43
 
35
44
  NO_TABLE = {}.freeze
36
45
  private_constant :NO_TABLE
@@ -144,17 +153,22 @@ module Rigor
144
153
  end
145
154
 
146
155
  # Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
147
- # report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` is
148
- # in it because two edges can otherwise agree on every other field, and `sort_by` is not stable.
156
+ # report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` and
157
+ # `super_call` are in it because two edges can otherwise agree on every other field (`def emit; super;
158
+ # emit; end` records both), and `sort_by` is not stable.
149
159
  def freeze_edges(table)
150
160
  return NO_TABLE if table.empty?
151
161
 
152
162
  table.transform_values do |list|
153
163
  sorted = list.uniq
154
- sorted.sort_by! { |edge| [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0] }
164
+ sorted.sort_by! { |edge| edge_order(edge) }
155
165
  sorted.freeze
156
166
  end.freeze
157
167
  end
168
+
169
+ def edge_order(edge)
170
+ [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0, edge.super_call ? 1 : 0]
171
+ end
158
172
  end
159
173
  end
160
174
  end
@@ -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
@@ -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