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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config_envelopes"
4
+ require_relative "envelope"
5
+ require_relative "method_key"
6
+ require_relative "registry"
7
+ require_relative "signature_sources"
8
+
9
+ module Rigor
10
+ module Effects
11
+ # The envelopes a **call site** may import as a `≤` bound, looked up by the callee the typer named
12
+ # (ADR-103 WD6; #386; normative in `docs/type-specification/effect-labels.md` § The declared lane at
13
+ # call sites).
14
+ #
15
+ # {EnvelopeCheck} asks "what bounds THIS method's body?" and answers it from the project's own
16
+ # declarations alone, because that is the stratum a contract can be checked against. This index asks
17
+ # the other question — "what does the thing I am calling promise?" — and so reads one stratum more:
18
+ # an **accepted signature**'s annotation, from the built RBS environment. WD6's trust ladder puts
19
+ # both on the discharging side, and the two questions are kept in two objects because only the first
20
+ # one may ever produce a finding.
21
+ #
22
+ # Four sources, nearest-first, exactly the check's precedence with the accepted stratum appended:
23
+ #
24
+ # per-method annotation > class-level annotation > `effects.envelopes:` > accepted signature
25
+ #
26
+ # The carrier is **nominal**: the lookup is by the receiver's *static* class name as the typer
27
+ # projected it (or, for an implicit-self call, by the unit's own class), never by walking ancestors.
28
+ # A structural interface erases to `Dynamic[top]` today and has nothing to attach a bound to
29
+ # ([ADR-103](../../../docs/adr/103-effect-labels.md) WD6); an inherited envelope reaches an override
30
+ # through `effect.liskov-widened` instead, which is a judgment rather than an import.
31
+ #
32
+ # Two bounds of the slice, both deliberate:
33
+ #
34
+ # - An `effects.envelopes:` entry selected by **`match:`** does not participate here. A path glob is
35
+ # a fact about where a class is *defined*, which a per-file collection window cannot see without
36
+ # the whole-project class-source table; `namespace:` needs only the name and does participate. The
37
+ # entry still bounds its own classes' methods for {EnvelopeCheck} and the Liskov check.
38
+ # - Lookup is by the *exact* owner. `repo.find` on a receiver typed `PgRepo` does not import
39
+ # `Repo#find`'s bound.
40
+ #
41
+ # Values are Marshal-clean ({Envelope} over frozen Strings and {LabelSet}s), because the index is
42
+ # built per process — the parent and each fork-pool worker build their own from the same
43
+ # configuration and the same signature content, so the two agree without a channel to keep in sync.
44
+ class EnvelopeIndex
45
+ NO_ENVELOPES = {}.freeze
46
+ private_constant :NO_ENVELOPES
47
+
48
+ NO_ENTRIES = [].freeze
49
+ private_constant :NO_ENTRIES
50
+
51
+ # The index a run with no declaration of any kind uses — and the fail-soft answer for a build that
52
+ # raised. Every lookup on it is one `empty?` read.
53
+ def self.empty
54
+ @empty ||= new
55
+ end
56
+
57
+ # Reads every stratum this index serves, once per process.
58
+ #
59
+ # @param configuration [Rigor::Configuration]
60
+ # @param plugin_facts [Rigor::Effects::PluginFacts, nil] the loaded plugins' effect contributions
61
+ # (#387); their `effect_labels:` join the vocabulary an annotation is read against, so a gem's
62
+ # `%a{rigor:v1:effect rails.activejob.enqueue}` resolves rather than reading as unknown.
63
+ # @param environment [Rigor::Environment, nil] the run's environment. Its loader supplies the
64
+ # rbs-inline / plugin `virtual_rbs` buffers and the built RBS environment the accepted stratum is
65
+ # read from; without one, both are simply absent (the fail-quiet direction — a missing `≤` bound
66
+ # costs precision, never a finding).
67
+ # @return [EnvelopeIndex]
68
+ def self.build(configuration:, environment: nil, plugin_facts: nil)
69
+ # Required here rather than at the top of the file: the reader pulls in the whole
70
+ # `RbsExtended` surface, and this build runs only under an `effects:` block.
71
+ require_relative "../rbs_extended/envelope_scanner"
72
+
73
+ registry = Registry.for_configuration(configuration, plugin_facts: plugin_facts)
74
+ loader = environment&.rbs_loader
75
+ scan = RbsExtended::EnvelopeScanner.scan(
76
+ sources: SignatureSources.collect(
77
+ signature_paths: configuration.signature_paths, virtual_rbs: loader&.virtual_rbs
78
+ ),
79
+ registry: registry
80
+ )
81
+ new(
82
+ method_envelopes: scan.method_envelopes, class_envelopes: scan.class_envelopes,
83
+ config_entries: ConfigEnvelopes.build(entries: configuration.effects_envelopes, registry: registry),
84
+ accepted: accepted_for(loader, registry)
85
+ )
86
+ rescue StandardError
87
+ empty
88
+ end
89
+
90
+ # The accepted stratum — every `%a{pure}` / `%a{rigor:v1:effect …}` on a method definition in the
91
+ # **built** RBS environment: a gem's shipped signatures, Rigor's bundled overlays, core RBS.
92
+ #
93
+ # It is read-only by construction. Nothing here can bound a project method's body, because the
94
+ # contract check ({EnvelopeCheck}) reads {RbsExtended::EnvelopeScanner}'s project-source tables and
95
+ # never this one; what an accepted signature can do is state what a call into it promises. The
96
+ # project's own signatures are in the built environment too and therefore appear here as well —
97
+ # harmlessly, since the project strata are consulted first and both discharge.
98
+ def self.accepted_for(loader, registry)
99
+ return NO_ENVELOPES if loader.nil?
100
+
101
+ RbsExtended::EnvelopeScanner.from_loader(loader: loader, registry: registry)
102
+ rescue StandardError
103
+ NO_ENVELOPES
104
+ end
105
+ private_class_method :accepted_for
106
+
107
+ def initialize(method_envelopes: NO_ENVELOPES, class_envelopes: NO_ENVELOPES,
108
+ config_entries: NO_ENTRIES, accepted: NO_ENVELOPES)
109
+ @method_envelopes = method_envelopes.freeze
110
+ @class_envelopes = class_envelopes.freeze
111
+ @config_entries = config_entries.reject { |entry| entry.namespace.nil? }.freeze
112
+ @accepted = accepted.freeze
113
+ @config_cache = {}
114
+ freeze
115
+ end
116
+
117
+ # Whether no stratum has anything to say. The scan's fast path: a project with no envelope of any
118
+ # kind pays one predicate per call site and nothing else.
119
+ def empty?
120
+ @method_envelopes.empty? && @class_envelopes.empty? && @config_entries.empty? && @accepted.empty?
121
+ end
122
+
123
+ # The envelope bounding `owner`'s `selector`, or nil.
124
+ #
125
+ # @param owner [String] the receiver's static class name, as the typer projected it
126
+ # @param singleton [Boolean] whether the call is `Owner.selector` rather than `Owner#selector`
127
+ # @param selector [String]
128
+ # @return [Envelope, nil] never a ⊤ envelope: a bound that bounds nothing is not a bound, and
129
+ # importing it would both add nothing and discharge a taint on the strength of a typo.
130
+ def [](owner, singleton, selector)
131
+ return nil if owner.nil? || empty?
132
+
133
+ key = "#{owner}#{singleton ? '.' : '#'}#{selector}"
134
+ envelope = @method_envelopes[key] || @class_envelopes[owner] || config_envelope(owner) ||
135
+ @accepted[key]
136
+ return nil if envelope.nil? || envelope.top?
137
+
138
+ envelope
139
+ end
140
+
141
+ private
142
+
143
+ # The first `namespace:` entry selecting `owner`, memoised per class name — one call site's owner
144
+ # is asked for once per site and a project has a handful of entries.
145
+ def config_envelope(owner)
146
+ return nil if @config_entries.empty?
147
+
148
+ if @config_cache.key?(owner)
149
+ @config_cache[owner]
150
+ else
151
+ entry = @config_entries.find { |candidate| ConfigEnvelopes.namespace_match?(candidate.namespace, owner) }
152
+ @config_cache[owner] = entry && ConfigEnvelopes.envelope_for(entry, owner)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "summary"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # What one analyzed file contributes to the project's effect graph (ADR-103 WD12).
8
+ #
9
+ # The collector produces one of these per file and the runner marshals it back from a fork-pool worker
10
+ # with the file's diagnostics, so every field is Marshal-clean: frozen Hashes of Strings, {Summary}
11
+ # values, and `Data` edges. Nothing here holds a Prism node, a `Scope` or an environment.
12
+ #
13
+ # It carries four tables:
14
+ #
15
+ # - {#summaries} — the **direct** summary of each method the file defines, keyed `Class#m` / `Class.m` /
16
+ # `<toplevel>#m` (WD14). A reopening in another file contributes the same key and the two join.
17
+ # - {#edges} — per method key, the calls that must be resolved against the *project* before they become
18
+ # graph edges. Resolution is deferred to the propagator because a file cannot see the whole class
19
+ # graph; the collector only records what the typer decided about the receiver.
20
+ # - {#superclasses} / {#includes} — the ancestry the propagator needs to resolve an edge through
21
+ # inherited methods and to find every project-known override of a call's target (the closed-world
22
+ # join of WD4).
23
+ #
24
+ # Merging is associative and commutative in every table, so folding a run's files in pool-completion
25
+ # order yields exactly the table sequential analysis yields.
26
+ class FileCollection
27
+ # One recorded call, before the project can say which definition it reaches.
28
+ #
29
+ # `receiver_class` is the class name the typer had at the call site (nil when it had none — a Dynamic
30
+ # receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
31
+ # `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
32
+ # whose failure to resolve is an `unresolved-self-call` taint rather than silence.
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
43
+
44
+ NO_TABLE = {}.freeze
45
+ private_constant :NO_TABLE
46
+
47
+ # The collection a file with nothing to say contributes — a parse failure, a file of constants, or a
48
+ # run where the collector was never activated.
49
+ def self.empty(path = nil)
50
+ new(path: path)
51
+ end
52
+
53
+ attr_reader :path, :summaries, :edges, :superclasses, :includes
54
+
55
+ def initialize(path: nil, summaries: NO_TABLE, edges: NO_TABLE,
56
+ superclasses: NO_TABLE, includes: NO_TABLE, failed: false)
57
+ @path = path
58
+ @summaries = freeze_table(summaries)
59
+ @edges = freeze_edges(edges)
60
+ @superclasses = freeze_table(superclasses)
61
+ @includes = freeze_table(includes)
62
+ @failed = failed ? true : false
63
+ freeze
64
+ end
65
+
66
+ # Whether the collector gave up on this file entirely (the fail-soft path). Its methods contribute
67
+ # nothing rather than contributing a wrong summary; `rigor check` is unaffected either way.
68
+ def failed?
69
+ @failed
70
+ end
71
+
72
+ def empty?
73
+ @summaries.empty? && @edges.empty?
74
+ end
75
+
76
+ # Folds another collection into this one. Summaries join per key, edge lists union, ancestry merges.
77
+ #
78
+ # **Fold a whole run with {merge_all}, not with this in a `reduce`.** Every call here rebuilds and
79
+ # re-freezes the accumulated tables, so folding a run one file at a time costs O(files × methods) —
80
+ # it was 5.1 s of mastodon's 6.4 s collection overhead and the whole of gitlab's superlinear one
81
+ # (`docs/notes/20260817-effect-collection-perf.md`). This stays for a two-collection merge, which is
82
+ # what its cost model fits.
83
+ def merge(other)
84
+ return self if other.empty? && !other.failed?
85
+
86
+ self.class.merge_all([self, other])
87
+ end
88
+
89
+ # Folds a run's collections in one linear pass: each key's summaries join once, each table is built
90
+ # once, and the frozen result is constructed once at the end. Order-independent in every table
91
+ # except `superclasses`, where a later collection's spelling wins exactly as a chain of {merge}
92
+ # calls would leave it, so a path-sorted fold is reproducible.
93
+ #
94
+ # A collection that is {empty?} and not {failed?} contributes nothing, which is {merge}'s own
95
+ # short-circuit spelled once: a file with no methods and no calls has no summary to fold, and its
96
+ # ancestry has no unit to attach to.
97
+ def self.merge_all(collections)
98
+ summaries = {}
99
+ edges = {}
100
+ superclasses = {}
101
+ includes = {}
102
+ failed = false
103
+
104
+ collections.each do |collection|
105
+ failed ||= collection.failed?
106
+ next if collection.empty?
107
+
108
+ fold_summaries(summaries, collection.summaries)
109
+ fold_lists(edges, collection.edges)
110
+ superclasses.update(collection.superclasses)
111
+ fold_lists(includes, collection.includes)
112
+ end
113
+
114
+ includes.each_value(&:uniq!)
115
+ new(path: nil, summaries: summaries, edges: edges,
116
+ superclasses: superclasses, includes: includes, failed: failed)
117
+ end
118
+
119
+ def self.fold_summaries(into, table)
120
+ table.each do |key, summary|
121
+ existing = into[key]
122
+ into[key] = existing ? existing.join(summary) : summary
123
+ end
124
+ end
125
+ private_class_method :fold_summaries
126
+
127
+ # De-duplication is deferred to the single pass at the end of {merge_all} — `freeze_edges` uniqs
128
+ # and sorts anyway, and uniqing per file is what made the fold quadratic.
129
+ def self.fold_lists(into, table)
130
+ table.each do |key, list|
131
+ existing = into[key]
132
+ existing ? existing.concat(list) : into[key] = list.dup
133
+ end
134
+ end
135
+ private_class_method :fold_lists
136
+
137
+ def ==(other)
138
+ other.is_a?(FileCollection) && other.summaries == @summaries && other.edges == @edges &&
139
+ other.superclasses == @superclasses && other.includes == @includes && other.failed? == @failed
140
+ end
141
+ alias eql? ==
142
+
143
+ def hash
144
+ [self.class, @summaries, @edges, @superclasses, @includes, @failed].hash
145
+ end
146
+
147
+ private
148
+
149
+ def freeze_table(table)
150
+ return NO_TABLE if table.empty?
151
+
152
+ table.transform_values { |value| value.is_a?(Array) ? value.freeze : value }.freeze
153
+ end
154
+
155
+ # Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
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.
159
+ def freeze_edges(table)
160
+ return NO_TABLE if table.empty?
161
+
162
+ table.transform_values do |list|
163
+ sorted = list.uniq
164
+ sorted.sort_by! { |edge| edge_order(edge) }
165
+ sorted.freeze
166
+ end.freeze
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
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "file_collection"
4
+ require_relative "label_set"
5
+ require_relative "origin"
6
+ require_relative "summary"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # The framework **edges** a plugin declared, materialised as effect units on the framework class itself
11
+ # (ADR-103 WD10; design note § 11.2 "Framework edges"; #387).
12
+ #
13
+ # `user.save` runs `User`'s `before_save :normalize`; `WelcomeJob.perform_now` runs
14
+ # `WelcomeJob#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. Each is real, synchronous,
15
+ # in-process control flow that the *syntax* does not contain, so without the plugin the caller's
16
+ # summary stops one hop short of the code that actually runs.
17
+ #
18
+ # ## Why units on the class rather than edges at the call site
19
+ #
20
+ # The call site is in another file. `app/controllers/users_controller.rb` writes `@user.save` and knows
21
+ # nothing about `User`'s callbacks — those are in `app/models/user.rb`, and a per-file collection window
22
+ # sees one file at a time. Synthesising `User#save` **where `User` is declared**, with edges to the
23
+ # callback methods, moves the framework knowledge to the file that has it and leaves the call site an
24
+ # ordinary edge. The propagator then resolves `(User, :instance, "save")` to the synthetic unit exactly
25
+ # as it resolves any other, ancestry and closed-world override join included — no second mechanism, no
26
+ # cross-file harvest, and a `save` on a subclass picks up that subclass's own callbacks for free.
27
+ #
28
+ # A project that defines `User#save` itself simply joins with the synthetic unit, which is the honest
29
+ # reading: an override still runs the callbacks unless it skips them.
30
+ #
31
+ # ## What is deliberately not here
32
+ #
33
+ # `perform_later` → `perform`. The deferred body runs in another process on another stack, and ADR-103
34
+ # WD4 fixes attribution to the code rather than to the clock: the enqueue is the effect, and the job's
35
+ # body belongs to the job's own entry point. {Plugin::EffectEdge::TARGETS} has no spelling for it.
36
+ module FrameworkUnits
37
+ IO_DB_READ = LabelSet.new(["io.db.read"]).freeze
38
+ private_constant :IO_DB_READ
39
+
40
+ # ActiveRecord's class-body callback macros, grouped by which persistence selectors run them. The
41
+ # grouping is coarse on purpose: an effect summary is an **upper bound**, and the cost of attributing
42
+ # a `before_create` to `save` (which does run it, when the record is new) is nothing, while the cost of
43
+ # a group fine enough to be wrong somewhere is a missing effect.
44
+ VALIDATION_MACROS = %w[validate before_validation after_validation].freeze
45
+
46
+ SAVE_MACROS = %w[
47
+ before_save around_save after_save before_create around_create after_create
48
+ before_update around_update after_update after_touch
49
+ before_commit after_commit after_rollback
50
+ after_create_commit after_update_commit after_save_commit
51
+ ].freeze
52
+
53
+ DESTROY_MACROS = %w[
54
+ before_destroy around_destroy after_destroy after_destroy_commit after_commit after_rollback
55
+ ].freeze
56
+
57
+ # Macros whose literal symbol arguments name methods on the same class. Everything the strategy reads.
58
+ CALLBACK_MACROS = (VALIDATION_MACROS + SAVE_MACROS + DESTROY_MACROS).uniq.freeze
59
+
60
+ # The uniqueness validator is a `SELECT` before the write — the one validation whose effect a reviewer
61
+ # is entitled to see in a summary. Both spellings Rails accepts.
62
+ UNIQUENESS_MACRO = "validates_uniqueness_of"
63
+ VALIDATES_MACRO = "validates"
64
+ UNIQUENESS_OPTION = "uniqueness"
65
+
66
+ # Which selectors run which group. `create` / `create!` are the singleton twins of `save`.
67
+ SAVE_TRIGGERS = %w[save save! update update! update_attribute touch increment! decrement!].freeze
68
+ DESTROY_TRIGGERS = %w[destroy destroy! delete].freeze
69
+ VALIDATION_TRIGGERS = %w[valid? invalid? validate!].freeze
70
+ SINGLETON_SAVE_TRIGGERS = %w[create create!].freeze
71
+
72
+ # `initialize` is not a mailer action, and neither is a method a plugin would want double-counted.
73
+ NON_ACTION_METHODS = %w[initialize].to_set.freeze
74
+
75
+ module_function
76
+
77
+ # Every synthetic unit `class_name` earns, as `[key, Summary, edges]` triples.
78
+ #
79
+ # @param class_name [String]
80
+ # @param instance_methods [Array<String>] the instance methods the class body defines, in source order
81
+ # @param macros [Hash{String=>Array<String>}] receiver-less class-body calls to their literal symbol
82
+ # arguments, as the scanner harvested them
83
+ # @param uniqueness [Boolean] whether the class body declares a uniqueness validator
84
+ # @param plugin_facts [PluginFacts]
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: {})
89
+ units = []
90
+ plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
91
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
92
+
93
+ units.concat(active_record_units(class_name, macros, uniqueness, plugin_facts, own_units))
94
+ end
95
+ plugin_facts.edges_for(:perform_now).each do |edge|
96
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
97
+
98
+ # `selector` (the edge's `method:`) names the synthesised selector, defaulting to
99
+ # `perform_now`. The one other value it
100
+ # ever takes is `perform_later` — and ONLY from a plugin that has read the project's own
101
+ # `queue_adapter = :inline`, where Rails really does run the job on the caller's stack. That is a
102
+ # project fact narrowing a transport, not a general edge (ADR-103 WD4).
103
+ units << unit("#{class_name}.#{edge.selector || :perform_now}", [edge_to(class_name, "perform")])
104
+ end
105
+ plugin_facts.edges_for(:mailer_body).each do |edge|
106
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
107
+
108
+ units.concat(mailer_units(class_name, instance_methods))
109
+ end
110
+ units
111
+ end
112
+
113
+ # `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
114
+ # no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
115
+ # every model in the project and say nothing.
116
+ def active_record_units(class_name, macros, uniqueness, plugin_facts, own_units)
117
+ validation = callbacks(macros, VALIDATION_MACROS)
118
+ save = validation + callbacks(macros, SAVE_MACROS)
119
+ destroy = callbacks(macros, DESTROY_MACROS)
120
+ read = uniqueness ? uniqueness_summary(class_name) : nil
121
+ context = { plugin_facts: plugin_facts, own_units: own_units }
122
+
123
+ units = []
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))
128
+ units
129
+ end
130
+
131
+ # ActionMailer's class-method-to-instance mapping: `UserMailer.welcome(u)` instantiates the mailer and
132
+ # runs `#welcome`. One synthetic singleton twin per instance method the mailer defines.
133
+ def mailer_units(class_name, instance_methods)
134
+ instance_methods.reject { |name| NON_ACTION_METHODS.include?(name) }.uniq.map do |name|
135
+ unit("#{class_name}.#{name}", [edge_to(class_name, name)])
136
+ end
137
+ end
138
+
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:)
143
+ return [] if targets.empty? && read.nil?
144
+
145
+ edges = targets.map { |target| edge_to(class_name, target) }
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
194
+ end
195
+
196
+ def callbacks(macros, names)
197
+ names.flat_map { |name| macros[name] || [] }.uniq
198
+ end
199
+
200
+ # The uniqueness validator's own query. It rides the DECLARED lane with no taint, exactly as every
201
+ # other first-party plugin contribution does (ADR-103 WD6): the plugin read the app's own
202
+ # `validates … uniqueness: true` and knows what Rails does with it, but the analyzer did not read a
203
+ # body, so this is a trusted claim rather than a proof.
204
+ def uniqueness_summary(class_name)
205
+ { Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
206
+ end
207
+
208
+ def unit(key, edges, declared = nil, causes = [])
209
+ summary = Summary.new(declared_bundles: declared || {}, exhaustive: causes.empty?, causes: causes)
210
+ [key, summary, edges]
211
+ end
212
+
213
+ def edge_to(class_name, selector)
214
+ FileCollection::Edge.new(receiver_class: class_name, kind: :instance, selector: selector,
215
+ self_call: false)
216
+ end
217
+
218
+ private_class_method :active_record_units, :mailer_units, :triggers, :framework_row, :replaced?,
219
+ :declared_bundles, :causes, :callbacks, :uniqueness_summary, :unit, :edge_to
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "../cache/descriptor"
7
+ require_relative "catalog"
8
+ require_relative "registry"
9
+
10
+ module Rigor
11
+ module Effects
12
+ # The **effects cache identity** — the second of ADR-103 WD13's "one cache, two identities, one extra
13
+ # slot" (issue #382; the contract is `docs/internal-spec/effect-summaries.md` § Caching).
14
+ #
15
+ # A run has one identity for its diagnostics and one for its effect summaries. The diagnostics identity
16
+ # is today's and is deliberately untouched by this file: collection is observational, so a diagnostics
17
+ # entry computed with effects on is valid for a run with effects off and vice versa. The effects
18
+ # identity is that identity **plus** the three things that change what a summary means without changing
19
+ # a single analyzed byte:
20
+ #
21
+ # - the **vocabulary version** ({Registry#vocabulary_version}) — a rename or a removal re-reads every
22
+ # persisted label;
23
+ # - the **catalogue identity** ({Catalog#identity}, schema + a digest of `data/effects/core.yml`) — an
24
+ # audited row moving from `io` to `io.fs.write` re-colours summaries the analyzed source never moved;
25
+ # - the **`effects:` block digest** — the same value the snapshot header carries, so a `tolerated:` or
26
+ # `reach:` edit is one visible regeneration event rather than two silently disagreeing digests;
27
+ # - the **plugin fact digest** ({PluginFacts#digest}, #387) — the loaded plugins' labels, attributions,
28
+ # edges and presets. A plugin upgrade that moves `perform_later` from `io` to `io.db.write` re-colours
29
+ # summaries the analyzed source never moved, exactly as a re-audited catalogue row does, and the
30
+ # plugin set is otherwise invisible to a key derived from configuration alone.
31
+ #
32
+ # This module is the ONE place that answers it. {.digest} is the string form (what the ADR-46 snapshot
33
+ # payload carries beside `return_summaries`) and {.descriptor} is the {Cache::Descriptor} form (what the
34
+ # ADR-45 whole-run effects slot is keyed by): the same three inputs, spelled for the two stores.
35
+ #
36
+ # Nothing here is consulted when collection is off — no entry is written, no key is perturbed, and the
37
+ # `effects:` block stays out of `Configuration#to_h` so enabling the feature invalidates no existing
38
+ # project's diagnostics cache.
39
+ module Identity
40
+ # The `configs:` slot the effects identity adds on top of a run's diagnostics key descriptor.
41
+ CONFIG_KEY = "effects.identity"
42
+
43
+ module_function
44
+
45
+ # The effects identity as a hex digest — the form a store with no descriptor of its own (the ADR-46
46
+ # incremental snapshot) carries alongside its payload, and compares verbatim on restore.
47
+ #
48
+ # @param configuration [Rigor::Configuration]
49
+ # @param registry [Registry] the vocabulary whose version participates
50
+ # @param catalog [Catalog] the catalogue whose identity participates
51
+ # @return [String] hex SHA-256
52
+ def digest(configuration:, registry: Registry.default, catalog: Catalog.default, plugin_facts: nil)
53
+ Digest::SHA256.hexdigest(
54
+ [
55
+ "vocabulary:#{registry.vocabulary_version}",
56
+ "catalog:#{catalog.identity}",
57
+ "effects:#{config_digest(configuration)}",
58
+ "plugins:#{plugin_facts&.digest || 'none'}"
59
+ ].join("\x00")
60
+ )
61
+ end
62
+
63
+ # The effects identity as a cache KEY descriptor: the run's own diagnostics key descriptor plus one
64
+ # `configs:` entry carrying {.digest}. Composing (rather than rebuilding) is what makes the effects
65
+ # slot inherit every diagnostics-invalidating input — engine version, engine source, RBS libraries,
66
+ # the analyzed-path set — for free, so "the effects identity is the diagnostics identity plus three
67
+ # things" is a property of the code rather than a claim about it.
68
+ #
69
+ # @param base [Cache::Descriptor] the run's diagnostics key descriptor
70
+ # @return [Cache::Descriptor]
71
+ def descriptor(base:, configuration:, registry: Registry.default, catalog: Catalog.default,
72
+ plugin_facts: nil)
73
+ Cache::Descriptor.compose(
74
+ base,
75
+ Cache::Descriptor.new(
76
+ configs: [
77
+ Cache::Descriptor::ConfigEntry.new(
78
+ key: CONFIG_KEY,
79
+ value_hash: digest(configuration: configuration, registry: registry, catalog: catalog,
80
+ plugin_facts: plugin_facts)
81
+ )
82
+ ]
83
+ )
84
+ )
85
+ end
86
+
87
+ # The `effects:` block of `.rigor.yml`, canonicalised (keys sorted at every depth, rendered as JSON)
88
+ # and hashed. {Snapshot.config_digest} is this method — the snapshot header and the cache identity
89
+ # MUST agree, and the cheapest way to guarantee that is for there to be one implementation.
90
+ def config_digest(configuration)
91
+ Digest::SHA256.hexdigest(JSON.generate(canonicalize(configuration.effects || {})))
92
+ end
93
+
94
+ def canonicalize(value)
95
+ case value
96
+ when Hash then value.map { |key, member| [key.to_s, canonicalize(member)] }.sort_by(&:first).to_h
97
+ when Array then value.map { |member| canonicalize(member) }
98
+ when Symbol then value.to_s
99
+ else value
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end