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,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ require_relative "../version"
8
+ require_relative "effect_table"
9
+ require_relative "entry_points"
10
+ require_relative "identity"
11
+ require_relative "registry"
12
+
13
+ module Rigor
14
+ module Effects
15
+ # The committed effect snapshot — `.rigor-effects.yml`, the primary validation mode (ADR-103 WD7 /
16
+ # WD14, design note § 9.4).
17
+ #
18
+ # It is `db/schema.rb` for effects: a generated artefact a project commits, whose diff in a pull
19
+ # request *is* the review signal, whose freshness CI checks, and where intent is expressed by
20
+ # committing the regenerated file rather than by annotating code. It emits no diagnostic and never
21
+ # enters `rigor check`'s stream ([ADR-102](../adr/102-unused-code-reachability-report.md)).
22
+ #
23
+ # Two tables, chosen so a diff is **attributable**:
24
+ #
25
+ # - `methods:` holds each unit's **direct** summary — origins in the method's own body (block literals
26
+ # included) plus catalogued and attributed callees, but never a project callee, which is an edge. An
27
+ # entry therefore moves only when its own lines, the catalogue or an attribution moved.
28
+ # - `reach:` holds the **transitive** footprint at the entry points `effects.snapshot.reach:` names.
29
+ # A leaf change fans out here, and the fan-out is the information (blast radius).
30
+ #
31
+ # The record is **undischarged**: `tolerated:` is consulted at judgment time by {SnapshotDiff}, never
32
+ # while writing, so `update --no-tolerated-effects` and `update` write byte-identical files and a
33
+ # policy change diffs the config rather than the record.
34
+ #
35
+ # The serialisation is a hand-rolled **JSON-compatible YAML subset**: string keys, JSON scalars, flow
36
+ # sequences, sorted keys and sorted labels, no anchors, no tags, no timestamps. `YAML.safe_load` of the
37
+ # file round-trips through `JSON` unchanged, which is the property a sibling implementation reads it by.
38
+ class Snapshot
39
+ # Bumped when the file's shape changes in a way an older reader would misread. A bump makes every
40
+ # existing file a regeneration event rather than a silent reinterpretation.
41
+ SCHEMA = 1
42
+
43
+ HEADER = "# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."
44
+
45
+ class ParseError < StandardError
46
+ end
47
+
48
+ # One row of either table.
49
+ #
50
+ # `effects` and `declared` are sorted label arrays; `unresolved` is the sorted list of taint-cause
51
+ # renderings behind a false `exhaustive`, and is empty when the row is exhaustive.
52
+ class Entry < Data.define(:key, :effects, :declared, :exhaustive, :unresolved)
53
+ def exhaustive?
54
+ exhaustive
55
+ end
56
+
57
+ # The wire form, with every field the reader can default omitted: `declared:` when empty,
58
+ # `exhaustive:` when true, `unresolved:` when there is nothing to say. What stays is what a
59
+ # reviewer reads.
60
+ def to_h
61
+ row = { "effects" => effects }
62
+ row["declared"] = declared unless declared.empty?
63
+ row["exhaustive"] = false unless exhaustive
64
+ row["unresolved"] = unresolved unless unresolved.empty?
65
+ row
66
+ end
67
+
68
+ def self.from_h(key, row)
69
+ raise ParseError, "entry #{key.inspect} is not a mapping" unless row.is_a?(Hash)
70
+
71
+ new(
72
+ key: key,
73
+ effects: string_list(row["effects"], key, "effects"),
74
+ declared: string_list(row["declared"], key, "declared"),
75
+ exhaustive: row.fetch("exhaustive", true) != false,
76
+ unresolved: string_list(row["unresolved"], key, "unresolved")
77
+ )
78
+ end
79
+
80
+ def self.string_list(value, key, field)
81
+ return [].freeze if value.nil?
82
+ raise ParseError, "#{key.inspect} #{field}: expected a list, got #{value.class}" unless value.is_a?(Array)
83
+
84
+ value.map do |member|
85
+ raise ParseError, "#{key.inspect} #{field}: expected strings" unless member.is_a?(String)
86
+
87
+ member
88
+ end.freeze
89
+ end
90
+ private_class_method :string_list
91
+ end
92
+
93
+ class << self
94
+ # Builds the snapshot a run's effect table describes.
95
+ #
96
+ # @param table [EffectTable] the propagated graph
97
+ # @param configuration [Rigor::Configuration] supplies the reach globs and the digested block
98
+ # @param sources [Hash{String=>Array<String>}] `Runner#effect_sources` — where each unit is defined
99
+ # @param full [Boolean] keep the rows {#omit?} drops
100
+ # @param registry [Registry] the vocabulary whose version the header carries
101
+ # @param project_root [String] what `sources` paths are relativised against for glob matching
102
+ def build(table:, configuration:, sources: {}, full: false, registry: Registry.default,
103
+ project_root: Dir.pwd)
104
+ globs = expand_reach(configuration.effects_snapshot_reach)
105
+ new(
106
+ header: {
107
+ "schema" => SCHEMA,
108
+ "rigor" => Rigor::VERSION,
109
+ "vocabulary" => registry.vocabulary_version,
110
+ "config_digest" => config_digest(configuration)
111
+ },
112
+ methods: build_methods(table, full: full),
113
+ reach: build_reach(table, globs, sources, project_root, full: full)
114
+ )
115
+ end
116
+
117
+ # The per-origin discharge judgment's input, for the side of a comparison that HAS origins
118
+ # (#385). `{table => {symbol => [label]}}`: for each row this snapshot carries, the labels that
119
+ # survive `effects.tolerated:` applied bundle by bundle — direct bundles under `methods:`, the
120
+ # transitive lane under `reach:`, matching what each table records.
121
+ #
122
+ # It is built beside the snapshot and never inside it: the file stays flat and undischarged, and
123
+ # this index exists only for the duration of one comparison ({SnapshotDiff}).
124
+ #
125
+ # @param snapshot [Snapshot] the current side, already built
126
+ # @param table [EffectTable] the run that produced it
127
+ # @param discharge [Discharge] the policy
128
+ # @return [Hash, nil] nil when the policy discharges nothing, which is the common case
129
+ def undischarged_index(snapshot:, table:, discharge:)
130
+ return nil if discharge.inert?
131
+
132
+ {
133
+ "methods" => snapshot.methods.keys.filter_map do |key|
134
+ entry = table[key]
135
+ [key, discharge.undischarged(entry.direct.bundles).to_a] if entry
136
+ end.to_h,
137
+ "reach" => snapshot.reach.keys.filter_map do |key|
138
+ entry = table[key]
139
+ [key, entry.undischarged.to_a] if entry
140
+ end.to_h
141
+ }.freeze
142
+ end
143
+
144
+ # The `effects:` block of `.rigor.yml`, canonicalised (keys sorted at every depth, rendered as
145
+ # JSON) and hashed. A Rigor upgrade, a vocabulary bump or a `tolerated:` edit is therefore a
146
+ # **visible** regeneration event rather than a silent reinterpretation of the record.
147
+ #
148
+ # The implementation lives in {Effects::Identity} because the effects CACHE identity (#382) digests
149
+ # the same block: a header that agreed with the cache key only by convention would eventually stop
150
+ # agreeing, and the failure would be a silently reused stale sidecar.
151
+ def config_digest(configuration)
152
+ Identity.config_digest(configuration)
153
+ end
154
+
155
+ # Resolves `effects.snapshot.reach:` entries to file globs: a preset name becomes the globs it
156
+ # stands for, a glob stays itself.
157
+ #
158
+ # This is where an unknown preset name is caught (#387). `Configuration` cannot: presets are named
159
+ # by plugins, and plugins load from the configuration being validated. By the time a snapshot is
160
+ # built the registered set is complete, so a name nothing registered is a real error — most often
161
+ # `reach: [rails]` in a project that never listed the Rails plugins — and saying so beats a `reach:`
162
+ # table that comes back mysteriously empty.
163
+ def expand_reach(entries)
164
+ entries.flat_map do |entry|
165
+ next [entry] if EntryPoints.glob?(entry)
166
+
167
+ EntryPoints.globs_for(entry) ||
168
+ raise(EntryPoints::Error,
169
+ "effects.snapshot.reach names no registered entry-point preset: #{entry.inspect} " \
170
+ "(registered: #{EntryPoints.names.inspect}; a preset is named by the plugin that " \
171
+ "models the framework, so listing that plugin is what registers it)")
172
+ end.uniq.sort.freeze
173
+ end
174
+
175
+ def parse(text)
176
+ data = YAML.safe_load(text)
177
+ raise ParseError, "not a mapping" unless data.is_a?(Hash)
178
+
179
+ new(
180
+ header: parse_header(data),
181
+ methods: parse_table(data["methods"], "methods"),
182
+ reach: parse_table(data["reach"], "reach")
183
+ )
184
+ rescue Psych::Exception => e
185
+ raise ParseError, e.message
186
+ end
187
+
188
+ def load(path)
189
+ parse(File.read(path, encoding: "UTF-8"))
190
+ end
191
+
192
+ private
193
+
194
+ def parse_header(data)
195
+ {
196
+ "schema" => data["schema"],
197
+ "rigor" => data["rigor"].to_s,
198
+ "vocabulary" => data["vocabulary"],
199
+ "config_digest" => data["config_digest"].to_s
200
+ }
201
+ end
202
+
203
+ def parse_table(value, name)
204
+ return {} if value.nil?
205
+ raise ParseError, "#{name}: expected a mapping, got #{value.class}" unless value.is_a?(Hash)
206
+
207
+ value.to_h { |key, row| [key.to_s, Entry.from_h(key.to_s, row)] }
208
+ end
209
+
210
+ # `methods:` records the DIRECT summary. Two omissions keep the file to what is worth reading:
211
+ # a summary that is exhaustive and proves nothing beyond `mutate.local` (which every envelope
212
+ # tolerates), and a synthesised default accessor summary. `--full` keeps both.
213
+ def build_methods(table, full:)
214
+ table.each_with_object({}) do |entry, out|
215
+ next if !full && omit?(trivial: entry.direct.trivial?, direct: entry.direct,
216
+ proven: entry.direct.proven, declared: entry.direct.declared)
217
+
218
+ out[entry.key] = entry_for(entry.key, entry.direct.proven, entry.direct,
219
+ declared: entry.direct.declared)
220
+ end
221
+ end
222
+
223
+ # `reach:` records the TRANSITIVE footprint, and only at the entry points the configuration names.
224
+ # A key qualifies when any file that defines it matches a glob — an entry-point glob selects a
225
+ # *file*, so every unit written in it is one, and the same omission rule then keeps the table to
226
+ # the entry points worth reading. One that later gains an effect arrives as an added symbol.
227
+ def build_reach(table, globs, sources, project_root, full:)
228
+ return {} if globs.empty?
229
+
230
+ table.each_with_object({}) do |entry, out|
231
+ next unless entry_point?(entry.key, globs, sources, project_root)
232
+ next if !full && omit?(trivial: entry.trivial?, direct: entry.direct,
233
+ proven: entry.proven, declared: entry.declared)
234
+
235
+ out[entry.key] = entry_for(entry.key, entry.proven, entry.direct, declared: entry.declared,
236
+ exhaustive: entry.exhaustive?,
237
+ causes: entry.causes)
238
+ end
239
+ end
240
+
241
+ def entry_point?(key, globs, sources, project_root)
242
+ Array(sources[key]).any? do |path|
243
+ relative = relativize(path, project_root)
244
+ globs.any? { |glob| File.fnmatch?(glob, relative, File::FNM_PATHNAME) }
245
+ end
246
+ end
247
+
248
+ def relativize(path, project_root)
249
+ absolute = File.absolute_path(path.to_s)
250
+ root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
251
+ absolute.start_with?(root) ? absolute[root.length..] : absolute
252
+ end
253
+
254
+ # Each table takes the lanes at ITS own reading: `methods:` records the direct summary, so its
255
+ # `declared:` is what this method's own body claims, and `reach:` records the transitive one, so
256
+ # its `declared:` is the fixpoint's — a controller reaching an attributed gem call through two
257
+ # services carries the claim, exactly as it carries the proven labels.
258
+ #
259
+ # The rendering rule applies here and only here: a declared label the row's own proven set already
260
+ # admits says nothing more, so it is dropped rather than printed as a second fact. The table keeps
261
+ # both lanes raw.
262
+ def entry_for(key, proven, direct, declared:, exhaustive: direct.exhaustive?, causes: direct.causes)
263
+ Entry.new(
264
+ key: key,
265
+ effects: proven.to_a,
266
+ declared: declared.excluding_subsumed_by(proven).to_a,
267
+ exhaustive: exhaustive,
268
+ unresolved: exhaustive ? [].freeze : render_causes(causes)
269
+ )
270
+ end
271
+
272
+ # `unresolved:` says why the row is not exhaustive, from the closed taint-cause enum of
273
+ # `docs/type-specification/effect-labels.md` — `dynamic-send`, `unresolved-self-call(save!)`. The
274
+ # design note sketched this field as "call names"; the collector keeps causes rather than names,
275
+ # and for the causes that have one the detail already IS the call name.
276
+ def render_causes(causes)
277
+ causes.map { |cause, detail| detail.nil? || detail.empty? ? cause : "#{cause}(#{detail})" }
278
+ .uniq.sort.freeze
279
+ end
280
+
281
+ # A synthesised default summary — an `attr_*` writer's `mutate.self`, and the `Struct` / `Data`
282
+ # accessors that join it when discovery synthesises them — carries no information a reviewer acts
283
+ # on: it restates the `attr_accessor` line. Recognised by its origins being exactly the synthesised
284
+ # constructs, so a hand-written `def name=` keeps its row.
285
+ SYNTHESISED_ORIGINS = %w[construct:attr-writer].to_set.freeze
286
+ private_constant :SYNTHESISED_ORIGINS
287
+
288
+ # A **taint-only** row carries no label in either lane: it says "not exhaustive, and here is why",
289
+ # and nothing a reviewer ratchets against. The snapshot lists what is attributable (issue #411,
290
+ # option (b)), so the row is omitted and the exhaustiveness transition stays visible for every row
291
+ # that does carry a label or a declared bound. `--full` keeps it, and a method that later proves a
292
+ # label arrives as an added symbol.
293
+ def omit?(trivial:, direct:, proven:, declared:)
294
+ return true if trivial
295
+ return true if proven.empty? && declared.empty?
296
+ return false unless direct.exhaustive?
297
+ return false if direct.bundles.empty?
298
+
299
+ direct.bundles.each_key.all? { |origin| SYNTHESISED_ORIGINS.include?(origin.to_s) }
300
+ end
301
+ end
302
+
303
+ attr_reader :header, :methods, :reach
304
+
305
+ def initialize(header:, methods: {}, reach: {})
306
+ @header = header.freeze
307
+ @methods = sorted(methods)
308
+ @reach = sorted(reach)
309
+ freeze
310
+ end
311
+
312
+ def ==(other)
313
+ other.is_a?(Snapshot) && other.header == @header && other.methods == @methods && other.reach == @reach
314
+ end
315
+ alias eql? ==
316
+
317
+ def hash
318
+ [self.class, @header, @methods, @reach].hash
319
+ end
320
+
321
+ # The table a diff category names, so `SnapshotDiff` walks the two by name.
322
+ def table(name)
323
+ name == "reach" ? @reach : @methods
324
+ end
325
+
326
+ # The wire form: plain Hashes, Strings, Integers, Booleans and Arrays — nothing a JSON parser would
327
+ # choke on and nothing a YAML reader has to resolve a tag for.
328
+ def to_h
329
+ @header.merge(
330
+ "methods" => @methods.transform_values(&:to_h),
331
+ "reach" => @reach.transform_values(&:to_h)
332
+ )
333
+ end
334
+
335
+ # Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
336
+ # the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
337
+ # by a pooled run must equal the sequential one bit for bit.
338
+ def to_yaml
339
+ lines = [HEADER]
340
+ lines << "schema: #{@header.fetch('schema')}"
341
+ lines << "rigor: #{scalar(@header.fetch('rigor'))}"
342
+ lines << "vocabulary: #{@header.fetch('vocabulary')}"
343
+ lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
344
+ render_table(lines, "methods", @methods)
345
+ render_table(lines, "reach", @reach)
346
+ "#{lines.join("\n")}\n"
347
+ end
348
+ alias to_s to_yaml
349
+
350
+ private
351
+
352
+ def render_table(lines, name, table)
353
+ if table.empty?
354
+ lines << "#{name}: {}"
355
+ return
356
+ end
357
+
358
+ lines << "#{name}:"
359
+ table.each do |key, entry|
360
+ lines << " #{scalar(key)}:"
361
+ entry.to_h.each { |field, value| lines << " #{field}: #{wire(value)}" }
362
+ end
363
+ end
364
+
365
+ def wire(value)
366
+ value.is_a?(Array) ? "[#{value.map { |member| scalar(member) }.join(', ')}]" : value.to_s
367
+ end
368
+
369
+ # JSON string escaping is a valid YAML double-quoted scalar, so one rule covers both formats and a
370
+ # key carrying `#`, `:` or a leading indicator cannot change the document's shape.
371
+ def scalar(value)
372
+ value.to_s.to_json
373
+ end
374
+
375
+ def sorted(table)
376
+ table.sort_by { |key, _| key }.to_h.freeze
377
+ end
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_set"
4
+ require_relative "snapshot"
5
+
6
+ module Rigor
7
+ module Effects
8
+ # Compares a committed {Snapshot} with the one this run computes, and judges the difference
9
+ # (ADR-103 WD7; the event vocabulary is design note § 9.4).
10
+ #
11
+ # The comparison itself is exact — drift between two observations by the same tool is 100 % precise.
12
+ # What is *judged* is whether the drift matters, and that has two knobs, applied here and never while
13
+ # writing the record:
14
+ #
15
+ # - the **gate**. `symmetric` (the default) fails on any drift, the `db/schema.rb` model: a removal is
16
+ # news too, because a job that stopped enqueueing is a bug rather than an improvement. `additions`
17
+ # is the ratchet: only growth fails.
18
+ # - the **tolerated set**. An event whose labels are all admitted by `effects.tolerated:` is reported
19
+ # under its own heading and does not fail the gate unless `strict_tolerated:` is set.
20
+ #
21
+ # **Additions are judged per origin; removals by label.** The file is flat — origins are `explain`'s
22
+ # job and a record keyed by them would churn on every refactor — but the *current* side of a
23
+ # comparison is a live effect table, so the caller can hand over `undischarged:`, the labels that
24
+ # survive per-origin discharge for each current symbol (#385). An added label is then tolerated
25
+ # exactly when every origin that introduces it is discharged, which is the same answer the envelope
26
+ # check gives, rather than the coarser "the label is on the list". A **removal** has no current-side
27
+ # origins to consult — the thing that produced it is gone — so it stays judged by label. Without the
28
+ # index (a caller that has no table, and every parse-only spec) both fall back to the label reading.
29
+ #
30
+ # A header mismatch is neither: it is a **regeneration event** — the record was written by a different
31
+ # Rigor, a different vocabulary or a different `effects:` block, so the two sides are not comparable
32
+ # in the first place. It fails under both gates, because there is nothing to ratchet against.
33
+ class SnapshotDiff
34
+ # Categories, in the order a text rendering emits them. The strings are the JSON contract.
35
+ MISSING_SNAPSHOT = "missing-snapshot"
36
+ REGENERATION = "regeneration"
37
+ SYMBOL_ADDED = "symbol-added"
38
+ SYMBOL_REMOVED = "symbol-removed"
39
+ LABEL_ADDED = "label-added"
40
+ LABEL_REMOVED = "label-removed"
41
+ DECLARED_ADDED = "declared-added"
42
+ DECLARED_REMOVED = "declared-removed"
43
+ MATERIALISED = "materialised"
44
+ EXHAUSTIVE_LOST = "exhaustive-lost"
45
+ EXHAUSTIVE_GAINED = "exhaustive-gained"
46
+
47
+ # What the `additions` ratchet still fails on: growth in either lane, a new symbol, and a method
48
+ # that stopped being exhaustive (someone introduced a call the analyzer cannot follow — the one
49
+ # "removal-shaped" change that is unambiguously a loss of knowledge).
50
+ ADDITIVE_CATEGORIES = [SYMBOL_ADDED, LABEL_ADDED, DECLARED_ADDED, EXHAUSTIVE_LOST,
51
+ MISSING_SNAPSHOT, REGENERATION].freeze
52
+
53
+ TABLES = %w[methods reach].freeze
54
+
55
+ # One difference.
56
+ #
57
+ # `symbol` is the method key (nil for a header event), `label` the label it is about (nil for the
58
+ # symbol- and exhaustiveness-shaped ones), `detail` any extra the renderer prints verbatim, and
59
+ # `tolerated` whether policy discharged it.
60
+ class Event < Data.define(:category, :symbol, :label, :table, :tolerated, :detail, :hedged)
61
+ def tolerated?
62
+ tolerated
63
+ end
64
+
65
+ # Whether the current side could not prove an absence. A removal read off a non-exhaustive summary
66
+ # is rendered hedged — "possibly more" cannot prove that something stopped happening.
67
+ def hedged?
68
+ hedged
69
+ end
70
+
71
+ def to_h
72
+ row = { "category" => category, "symbol" => symbol, "table" => table, "tolerated" => tolerated }
73
+ row["label"] = label if label
74
+ row["detail"] = detail if detail
75
+ row["hedged"] = true if hedged
76
+ row
77
+ end
78
+ end
79
+
80
+ # The categories the per-origin judgment is exact for: both are additions in the PROVEN lane, so the
81
+ # current side's undischarged labels answer them directly. The declared lane's additions are not
82
+ # here — nothing declared has a proven origin to discharge.
83
+ ORIGIN_JUDGED_CATEGORIES = [SYMBOL_ADDED, LABEL_ADDED].freeze
84
+ private_constant :ORIGIN_JUDGED_CATEGORIES
85
+
86
+ # Compares `current` against `recorded`. `recorded` may be nil — no snapshot on disk at all, which
87
+ # is drift with a routed message rather than an error.
88
+ #
89
+ # @param undischarged [Hash] `{table name => {symbol => [label]}}` — the current side's labels that
90
+ # survive per-origin discharge ({Snapshot.undischarged_index}). Optional; omitting it judges every
91
+ # event by label.
92
+ def self.compare(recorded:, current:, tolerated: [], gate: :symmetric, strict_tolerated: false,
93
+ undischarged: nil)
94
+ new(recorded: recorded, current: current, tolerated: tolerated, gate: gate,
95
+ strict_tolerated: strict_tolerated, undischarged: undischarged)
96
+ end
97
+
98
+ attr_reader :events, :recorded, :current, :gate
99
+
100
+ def initialize(recorded:, current:, tolerated: [], gate: :symmetric, strict_tolerated: false,
101
+ undischarged: nil)
102
+ @recorded = recorded
103
+ @current = current
104
+ @gate = gate
105
+ @strict_tolerated = strict_tolerated
106
+ @tolerated = LabelSet.new(tolerated)
107
+ @undischarged = undischarged
108
+ @added_symbols = 0
109
+ @removed_symbols = 0
110
+ @events = build_events.freeze
111
+ freeze
112
+ end
113
+
114
+ # Whether the record matches what this run computed. A tolerated-only difference is NOT fresh — the
115
+ # file still needs regenerating — it merely does not fail the gate.
116
+ def fresh?
117
+ @events.empty?
118
+ end
119
+
120
+ # Whether `rigor effects check` exits non-zero.
121
+ def drift?
122
+ @events.any? { |event| gating?(event) }
123
+ end
124
+
125
+ # Renames are a removal plus an addition and are never reported as a lost effect; the footer is
126
+ # where the reviewer sees that the two counts balance.
127
+ def footer
128
+ { added_symbols: @added_symbols, removed_symbols: @removed_symbols }
129
+ end
130
+
131
+ def events_for(table)
132
+ @events.select { |event| event.table == table && !event.tolerated? }
133
+ end
134
+
135
+ def tolerated_events
136
+ @events.select(&:tolerated?)
137
+ end
138
+
139
+ private
140
+
141
+ def gating?(event)
142
+ return false if event.tolerated? && !@strict_tolerated
143
+ return true if @gate == :symmetric
144
+
145
+ ADDITIVE_CATEGORIES.include?(event.category)
146
+ end
147
+
148
+ def build_events
149
+ return [missing_snapshot_event] if @recorded.nil?
150
+
151
+ header_events + TABLES.flat_map { |table| table_events(table) }
152
+ end
153
+
154
+ def missing_snapshot_event
155
+ event(MISSING_SNAPSHOT, symbol: nil, table: "snapshot",
156
+ detail: "no snapshot; run `rigor effects update`")
157
+ end
158
+
159
+ # The header says which record this is. A mismatch on any of its four fields means the recorded
160
+ # sets were computed under different rules, so every downstream comparison would be noise.
161
+ def header_events
162
+ %w[schema rigor vocabulary config_digest].filter_map do |field|
163
+ was = @recorded.header[field]
164
+ now = @current.header[field]
165
+ next if was == now
166
+
167
+ event(REGENERATION, symbol: nil, table: "header", detail: "#{field}: #{was.inspect} → #{now.inspect}")
168
+ end
169
+ end
170
+
171
+ def table_events(table)
172
+ was = @recorded.table(table)
173
+ now = @current.table(table)
174
+ (was.keys | now.keys).sort.flat_map do |symbol|
175
+ before = was[symbol]
176
+ after = now[symbol]
177
+ if before.nil? then added_symbol(symbol, after, table)
178
+ elsif after.nil? then removed_symbol(symbol, before, table)
179
+ else changed_symbol(symbol, before, after, table)
180
+ end
181
+ end
182
+ end
183
+
184
+ def added_symbol(symbol, entry, table)
185
+ @added_symbols += 1
186
+ return [] if quiet?(entry)
187
+
188
+ [event(SYMBOL_ADDED, symbol: symbol, table: table, labels: entry.effects,
189
+ detail: render_labels(entry))]
190
+ end
191
+
192
+ def removed_symbol(symbol, entry, table)
193
+ @removed_symbols += 1
194
+ return [] if quiet?(entry)
195
+
196
+ [event(SYMBOL_REMOVED, symbol: symbol, table: table, labels: entry.effects,
197
+ detail: render_labels(entry))]
198
+ end
199
+
200
+ # A row carrying nothing — no labels, nothing declared, exhaustive — appears only under `--full`.
201
+ # Its coming and going is not news; the footer still counts it, so a rename still balances.
202
+ def quiet?(entry)
203
+ entry.effects.empty? && entry.declared.empty? && entry.exhaustive?
204
+ end
205
+
206
+ def changed_symbol(symbol, before, after, table)
207
+ materialised = before.declared & (after.effects - before.effects)
208
+ lane_events(symbol, before, after, table, materialised) +
209
+ exhaustiveness_events(symbol, before, after, table)
210
+ end
211
+
212
+ # The proven and declared lanes, with materialisation lifted out of both: a label that was an
213
+ # authored upper bound and is now proven is ONE event, never a removal plus an addition.
214
+ def lane_events(symbol, before, after, table, materialised)
215
+ [[MATERIALISED, materialised],
216
+ [LABEL_ADDED, after.effects - before.effects - materialised],
217
+ [LABEL_REMOVED, before.effects - after.effects],
218
+ [DECLARED_ADDED, after.declared - before.declared],
219
+ [DECLARED_REMOVED, before.declared - after.declared - materialised]].flat_map do |category, labels|
220
+ hedged = category == LABEL_REMOVED && !after.exhaustive?
221
+ labels.map { |label| event(category, symbol: symbol, table: table, label: label, hedged: hedged) }
222
+ end
223
+ end
224
+
225
+ def exhaustiveness_events(symbol, before, after, table)
226
+ return [] if before.exhaustive? == after.exhaustive?
227
+
228
+ category = after.exhaustive? ? EXHAUSTIVE_GAINED : EXHAUSTIVE_LOST
229
+ [event(category, symbol: symbol, table: table,
230
+ detail: after.exhaustive? ? "not → exhaustive" : "exhaustive → not")]
231
+ end
232
+
233
+ def render_labels(entry)
234
+ "[#{entry.effects.join(', ')}]#{' …?' unless entry.exhaustive?}"
235
+ end
236
+
237
+ # Policy discharge, at judgment time. A single-label event is tolerated when the policy discharges
238
+ # that label; a symbol event when it discharges every label the symbol carries — and never when it
239
+ # carries none, because an empty set is discharged by nothing.
240
+ def event(category, symbol:, table:, label: nil, labels: nil, detail: nil, hedged: false)
241
+ Event.new(category: category, symbol: symbol, label: label, table: table, detail: detail,
242
+ hedged: hedged, tolerated: tolerated?(category, symbol, table, label, labels))
243
+ end
244
+
245
+ def tolerated?(category, symbol, table, label, labels)
246
+ members = labels || (label ? [label] : nil)
247
+ return false if members.nil? || members.empty? || @tolerated.empty?
248
+
249
+ surviving = surviving_labels(category, symbol, table)
250
+ return members.none? { |member| surviving.include?(member) } if surviving
251
+
252
+ members.all? { |member| @tolerated.admits?(member) }
253
+ end
254
+
255
+ # The current side's undischarged labels for one symbol, or nil when this event is not one the
256
+ # per-origin judgment answers — no index, a removal, or a lane with no proven origins.
257
+ def surviving_labels(category, symbol, table)
258
+ return nil if @undischarged.nil? || symbol.nil?
259
+ return nil unless ORIGIN_JUDGED_CATEGORIES.include?(category)
260
+
261
+ @undischarged.dig(table, symbol)
262
+ end
263
+ end
264
+ end
265
+ end