rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  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 +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,160 @@
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
+ Edge = Data.define(:receiver_class, :kind, :selector, :self_call)
34
+
35
+ NO_TABLE = {}.freeze
36
+ private_constant :NO_TABLE
37
+
38
+ # The collection a file with nothing to say contributes — a parse failure, a file of constants, or a
39
+ # run where the collector was never activated.
40
+ def self.empty(path = nil)
41
+ new(path: path)
42
+ end
43
+
44
+ attr_reader :path, :summaries, :edges, :superclasses, :includes
45
+
46
+ def initialize(path: nil, summaries: NO_TABLE, edges: NO_TABLE,
47
+ superclasses: NO_TABLE, includes: NO_TABLE, failed: false)
48
+ @path = path
49
+ @summaries = freeze_table(summaries)
50
+ @edges = freeze_edges(edges)
51
+ @superclasses = freeze_table(superclasses)
52
+ @includes = freeze_table(includes)
53
+ @failed = failed ? true : false
54
+ freeze
55
+ end
56
+
57
+ # Whether the collector gave up on this file entirely (the fail-soft path). Its methods contribute
58
+ # nothing rather than contributing a wrong summary; `rigor check` is unaffected either way.
59
+ def failed?
60
+ @failed
61
+ end
62
+
63
+ def empty?
64
+ @summaries.empty? && @edges.empty?
65
+ end
66
+
67
+ # Folds another collection into this one. Summaries join per key, edge lists union, ancestry merges.
68
+ #
69
+ # **Fold a whole run with {merge_all}, not with this in a `reduce`.** Every call here rebuilds and
70
+ # re-freezes the accumulated tables, so folding a run one file at a time costs O(files × methods) —
71
+ # it was 5.1 s of mastodon's 6.4 s collection overhead and the whole of gitlab's superlinear one
72
+ # (`docs/notes/20260817-effect-collection-perf.md`). This stays for a two-collection merge, which is
73
+ # what its cost model fits.
74
+ def merge(other)
75
+ return self if other.empty? && !other.failed?
76
+
77
+ self.class.merge_all([self, other])
78
+ end
79
+
80
+ # Folds a run's collections in one linear pass: each key's summaries join once, each table is built
81
+ # once, and the frozen result is constructed once at the end. Order-independent in every table
82
+ # except `superclasses`, where a later collection's spelling wins exactly as a chain of {merge}
83
+ # calls would leave it, so a path-sorted fold is reproducible.
84
+ #
85
+ # A collection that is {empty?} and not {failed?} contributes nothing, which is {merge}'s own
86
+ # short-circuit spelled once: a file with no methods and no calls has no summary to fold, and its
87
+ # ancestry has no unit to attach to.
88
+ def self.merge_all(collections)
89
+ summaries = {}
90
+ edges = {}
91
+ superclasses = {}
92
+ includes = {}
93
+ failed = false
94
+
95
+ collections.each do |collection|
96
+ failed ||= collection.failed?
97
+ next if collection.empty?
98
+
99
+ fold_summaries(summaries, collection.summaries)
100
+ fold_lists(edges, collection.edges)
101
+ superclasses.update(collection.superclasses)
102
+ fold_lists(includes, collection.includes)
103
+ end
104
+
105
+ includes.each_value(&:uniq!)
106
+ new(path: nil, summaries: summaries, edges: edges,
107
+ superclasses: superclasses, includes: includes, failed: failed)
108
+ end
109
+
110
+ def self.fold_summaries(into, table)
111
+ table.each do |key, summary|
112
+ existing = into[key]
113
+ into[key] = existing ? existing.join(summary) : summary
114
+ end
115
+ end
116
+ private_class_method :fold_summaries
117
+
118
+ # De-duplication is deferred to the single pass at the end of {merge_all} — `freeze_edges` uniqs
119
+ # and sorts anyway, and uniqing per file is what made the fold quadratic.
120
+ def self.fold_lists(into, table)
121
+ table.each do |key, list|
122
+ existing = into[key]
123
+ existing ? existing.concat(list) : into[key] = list.dup
124
+ end
125
+ end
126
+ private_class_method :fold_lists
127
+
128
+ def ==(other)
129
+ other.is_a?(FileCollection) && other.summaries == @summaries && other.edges == @edges &&
130
+ other.superclasses == @superclasses && other.includes == @includes && other.failed? == @failed
131
+ end
132
+ alias eql? ==
133
+
134
+ def hash
135
+ [self.class, @summaries, @edges, @superclasses, @includes, @failed].hash
136
+ end
137
+
138
+ private
139
+
140
+ def freeze_table(table)
141
+ return NO_TABLE if table.empty?
142
+
143
+ table.transform_values { |value| value.is_a?(Array) ? value.freeze : value }.freeze
144
+ end
145
+
146
+ # 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.
149
+ def freeze_edges(table)
150
+ return NO_TABLE if table.empty?
151
+
152
+ table.transform_values do |list|
153
+ sorted = list.uniq
154
+ sorted.sort_by! { |edge| [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0] }
155
+ sorted.freeze
156
+ end.freeze
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,167 @@
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
+ def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:)
86
+ units = []
87
+ plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
88
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
89
+
90
+ units.concat(active_record_units(class_name, macros, uniqueness))
91
+ end
92
+ plugin_facts.edges_for(:perform_now).each do |edge|
93
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
94
+
95
+ # `selector` (the edge's `method:`) names the synthesised selector, defaulting to
96
+ # `perform_now`. The one other value it
97
+ # ever takes is `perform_later` — and ONLY from a plugin that has read the project's own
98
+ # `queue_adapter = :inline`, where Rails really does run the job on the caller's stack. That is a
99
+ # project fact narrowing a transport, not a general edge (ADR-103 WD4).
100
+ units << unit("#{class_name}.#{edge.selector || :perform_now}", [edge_to(class_name, "perform")])
101
+ end
102
+ plugin_facts.edges_for(:mailer_body).each do |edge|
103
+ next unless plugin_facts.descends_from?(class_name, edge.receiver)
104
+
105
+ units.concat(mailer_units(class_name, instance_methods))
106
+ end
107
+ units
108
+ end
109
+
110
+ # `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
111
+ # no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
112
+ # every model in the project and say nothing.
113
+ def active_record_units(class_name, macros, uniqueness)
114
+ validation = callbacks(macros, VALIDATION_MACROS)
115
+ save = validation + callbacks(macros, SAVE_MACROS)
116
+ destroy = callbacks(macros, DESTROY_MACROS)
117
+ read = uniqueness ? uniqueness_summary(class_name) : nil
118
+
119
+ 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
125
+ end
126
+
127
+ # ActionMailer's class-method-to-instance mapping: `UserMailer.welcome(u)` instantiates the mailer and
128
+ # runs `#welcome`. One synthetic singleton twin per instance method the mailer defines.
129
+ def mailer_units(class_name, instance_methods)
130
+ instance_methods.reject { |name| NON_ACTION_METHODS.include?(name) }.uniq.map do |name|
131
+ unit("#{class_name}.#{name}", [edge_to(class_name, name)])
132
+ end
133
+ end
134
+
135
+ def triggers(class_name, selectors, targets, read, singleton:)
136
+ return [] if targets.empty? && read.nil?
137
+
138
+ edges = targets.map { |target| edge_to(class_name, target) }
139
+ selectors.map { |selector| unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges, read) }
140
+ end
141
+
142
+ def callbacks(macros, names)
143
+ names.flat_map { |name| macros[name] || [] }.uniq
144
+ end
145
+
146
+ # The uniqueness validator's own query. It rides the DECLARED lane with no taint, exactly as every
147
+ # other first-party plugin contribution does (ADR-103 WD6): the plugin read the app's own
148
+ # `validates … uniqueness: true` and knows what Rails does with it, but the analyzer did not read a
149
+ # body, so this is a trusted claim rather than a proof.
150
+ def uniqueness_summary(class_name)
151
+ { Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
152
+ end
153
+
154
+ def unit(key, edges, declared = nil)
155
+ [key, Summary.new(declared_bundles: declared || {}), edges]
156
+ end
157
+
158
+ def edge_to(class_name, selector)
159
+ FileCollection::Edge.new(receiver_class: class_name, kind: :instance, selector: selector,
160
+ self_call: false)
161
+ end
162
+
163
+ private_class_method :active_record_units, :mailer_units, :triggers, :callbacks, :uniqueness_summary,
164
+ :unit, :edge_to
165
+ end
166
+ end
167
+ 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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Effects
5
+ # The effect-label grammar and the subsumption relation over it (ADR-103 WD1; normative in
6
+ # `docs/type-specification/effect-labels.md`).
7
+ #
8
+ # A label is a dot-path of lowercase segments — `io`, `io.net.http`, `nondet.time`. The relation
9
+ # that matters is **segment-aware prefix subsumption**: `io` admits `io.net.http` and rejects
10
+ # `iota`. Every method here is pure and total; a malformed input is answered, never raised on,
11
+ # so a caller can ask `valid?` and the rest in either order.
12
+ module Label
13
+ # `label = segment { "." segment }`, `segment = [a-z][a-z0-9]*`. Deliberately narrow: no
14
+ # underscores, no hyphens, no uppercase, no empty segment, no trailing dot. The same grammar
15
+ # is the RFC's `label` production, so a label spells identically in Steins and Rigor.
16
+ PATTERN = /\A[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*\z/
17
+
18
+ SEPARATOR = "."
19
+ private_constant :SEPARATOR
20
+
21
+ module_function
22
+
23
+ # Whether `str` is a well-formed label. Anything that is not a String is not.
24
+ def valid?(str)
25
+ str.is_a?(String) && PATTERN.match?(str)
26
+ end
27
+
28
+ # The label's segments, outermost first: `"io.net.http"` -> `["io", "net", "http"]`.
29
+ # A malformed label yields an empty array rather than a partial parse.
30
+ def segments(label)
31
+ return [].freeze unless valid?(label)
32
+
33
+ label.split(SEPARATOR).freeze
34
+ end
35
+
36
+ # Whether `bound` admits `label` under segment-aware prefix subsumption. A label subsumes
37
+ # itself; `io` subsumes `io.net.http`; `io` does NOT subsume `iota`, because the match is on
38
+ # segment boundaries and not on characters.
39
+ def subsumes?(bound, label)
40
+ return false unless valid?(bound) && valid?(label)
41
+ return true if bound == label
42
+
43
+ label.start_with?("#{bound}#{SEPARATOR}")
44
+ end
45
+
46
+ # The label one segment shallower, or `nil` for a root (and for a malformed label).
47
+ def parent(label)
48
+ return nil unless valid?(label)
49
+
50
+ index = label.rindex(SEPARATOR)
51
+ return nil unless index
52
+
53
+ label[0, index]
54
+ end
55
+
56
+ # The label's proper ancestors, outermost first and excluding the label itself:
57
+ # `"io.net.http"` -> `["io", "io.net"]`. A root has no ancestors.
58
+ def ancestors(label)
59
+ parts = segments(label)
60
+ return [].freeze if parts.length <= 1
61
+
62
+ result = []
63
+ prefix = nil
64
+ parts[0...-1].each do |segment|
65
+ prefix = prefix ? "#{prefix}#{SEPARATOR}#{segment}" : segment
66
+ result << prefix
67
+ end
68
+ result.freeze
69
+ end
70
+
71
+ # The label's outermost segment — the root whose ownership the registry checks.
72
+ def root(label)
73
+ segments(label).first
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # Whether an unrecognised effect label is evidence of a **typo** rather than of a word that was
8
+ # never meant to be a label at all (ADR-103 WD1; normative in
9
+ # `docs/type-specification/effect-labels.md` § Unknown labels).
10
+ #
11
+ # The degradation an unknown label causes is silent by construction — the tag reads ⊤ and stops
12
+ # bounding anything — so a paired diagnostic is the only thing that keeps the fail-open rule
13
+ # honest. But the diagnostic cannot fire on every unrecognised spelling: a vocabulary is open by
14
+ # design (`effects.labels:`, a plugin's own root), so an unknown word is as likely to be a label
15
+ # this project has not registered yet as it is to be a misspelling. Reporting both would put a
16
+ # finding on correct-by-intent code, which is the direction the false-positive budget is not
17
+ # allowed to run ([ADR-5](../../../docs/adr/5-robustness-principle.md)).
18
+ #
19
+ # Intent is therefore read off four signals, any one of which is enough:
20
+ #
21
+ # 1. **A near miss** — the spelling is within {Registry::SUGGESTION_DISTANCE_CAP} edits of a
22
+ # label the registry knows (`io.bd` against `io.db`).
23
+ # 2. **A known sibling** — another member of the same comma-separated list is recognised, so the
24
+ # list as a whole is demonstrably written in this vocabulary.
25
+ # 3. **A dotted path** — the token carries two or more segments (`io.netw`). Nothing but a label
26
+ # is spelled that way; a project opening its own root writes a bare word first.
27
+ # 4. **A retired spelling** — the registry's `retired:` table names it, so the author wrote a
28
+ # label that WAS correct and a vocabulary bump moved it.
29
+ #
30
+ # A lone far-off word (`%a{rigor:v1:effect database}`) matches none of them and stays silent
31
+ # everywhere. It still degrades the tag to ⊤ — the reading never depends on this module.
32
+ module LabelIntent
33
+ # How many dot-separated segments make a token unmistakably label-shaped (signal 3).
34
+ MULTI_SEGMENT_ARITY = 2
35
+
36
+ module_function
37
+
38
+ # Whether reporting `token` as an unknown label is justified.
39
+ #
40
+ # @param token [String] the spelling as written.
41
+ # @param registry [Rigor::Effects::Registry, nil] the vocabulary AFTER plugin load; `nil` (no
42
+ # vocabulary at all) makes every token unjudgeable and therefore silent.
43
+ # @param siblings [Array<String>] the other tokens of the same list / the same config value.
44
+ # @return [Boolean]
45
+ def evident?(token, registry, siblings: [])
46
+ return false if registry.nil?
47
+ return false unless Label.valid?(token)
48
+ return false if registry.known?(token)
49
+
50
+ retired?(token, registry) || near_miss?(token, registry) ||
51
+ multi_segment?(token) || known_sibling?(token, registry, siblings)
52
+ end
53
+
54
+ def retired?(token, registry)
55
+ !Array(registry.retired(token)).empty?
56
+ end
57
+
58
+ def near_miss?(token, registry)
59
+ !registry.suggest(token).nil?
60
+ end
61
+
62
+ def multi_segment?(token)
63
+ Label.segments(token).length >= MULTI_SEGMENT_ARITY
64
+ end
65
+
66
+ def known_sibling?(token, registry, siblings)
67
+ Array(siblings).any? { |sibling| sibling != token && registry.known?(sibling.to_s) }
68
+ end
69
+
70
+ private_class_method :retired?, :near_miss?, :multi_segment?, :known_sibling?
71
+ end
72
+ end
73
+ end