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
@@ -10,6 +10,9 @@ require_relative "../rbs_extended/reporter"
10
10
  require_relative "../reflection"
11
11
  require_relative "../type/combinator"
12
12
  require_relative "../inference/coverage_scanner"
13
+ require_relative "../effects/attribution"
14
+ require_relative "../effects/collector"
15
+ require_relative "../effects/envelope_index"
13
16
  require_relative "../inference/scope_indexer"
14
17
  require_relative "../inference/method_dispatcher/file_folding"
15
18
  require_relative "check_rules"
@@ -93,6 +96,14 @@ module Rigor
93
96
  # normal `--workers N` run leaves it false and pays nothing (the recorder's disabled fast path).
94
97
  @record_dependencies = record_dependencies
95
98
  @file_dependencies = {}
99
+ # ADR-103 WD13 — effect collection, derived from the configuration exactly as `Runner` derives it,
100
+ # so a worker and the parent agree without a flag to keep in sync. Records marshal back through
101
+ # {#drain_effects}.
102
+ @record_effects = configuration.effects_enabled?
103
+ # ADR-103 WD6 / #385 — same table, derived the same way, for the same reason as `@record_effects`
104
+ # above: a worker and the parent agree without a flag to keep in sync.
105
+ @effect_attribution = Effects::Attribution.build(configuration.effects_attribution)
106
+ @file_effects = {}
96
107
  # ADR-32 WD4 — full project file list (frozen Array<String>) for env-build-time invocation of any
97
108
  # loaded plugin's `source_rbs_synthesizer` callable.
98
109
  @source_files = source_files
@@ -144,14 +155,50 @@ module Rigor
144
155
  # worker captures the file's cross-file reads for {#drain_dependencies}; the recorder's own disabled
145
156
  # fast path makes the unwrapped case free.
146
157
  def analyze(path)
147
- return analyze_body(path) unless @record_dependencies
158
+ return analyze_with_effects(path) unless @record_dependencies
148
159
 
149
160
  diagnostics = nil
150
- record = DependencyRecorder.record_for(path) { diagnostics = analyze_body(path) }
161
+ record = DependencyRecorder.record_for(path) { diagnostics = analyze_with_effects(path) }
151
162
  @file_dependencies[path] = record
152
163
  diagnostics
153
164
  end
154
165
 
166
+ # ADR-103 WD13 — the worker-side mirror of `Runner#analyze_with_effects`.
167
+ def analyze_with_effects(path)
168
+ return analyze_body(path) unless @record_effects
169
+
170
+ diagnostics = nil
171
+ collection = Effects::Collector.collect_for(
172
+ path, attribution: @effect_attribution, envelopes: effect_envelope_index,
173
+ plugin_facts: effect_plugin_facts
174
+ ) do
175
+ diagnostics = analyze_body(path)
176
+ end
177
+ @file_effects[path] = collection
178
+ diagnostics
179
+ end
180
+ private :analyze_with_effects
181
+
182
+ # ADR-103 WD6 / #386 — the worker-side mirror of `Runner#effect_envelope_index`. Derived from the
183
+ # same configuration and the session's own environment, so a worker's `≤` lane is the one the
184
+ # parent would have computed for the same file.
185
+ # ADR-103 WD10 / #387 — the worker-side mirror of `Runner#effect_plugin_facts`. The superclass table
186
+ # arrives with the coordinator's scope seed, so a worker resolves an `ActiveRecord::Base` row through
187
+ # the same project ancestry the parent would have walked.
188
+ def effect_plugin_facts
189
+ @effect_plugin_facts ||= Effects::PluginFacts.build(
190
+ @plugin_registry, superclasses: @project_scope_seed[:discovered_superclasses] || {}
191
+ )
192
+ end
193
+ private :effect_plugin_facts
194
+
195
+ def effect_envelope_index
196
+ @effect_envelope_index ||= Effects::EnvelopeIndex.build(
197
+ configuration: @configuration, environment: @environment, plugin_facts: effect_plugin_facts
198
+ )
199
+ end
200
+ private :effect_envelope_index
201
+
155
202
  def analyze_body(path)
156
203
  parse_result = parse_source(path)
157
204
  unless parse_result.errors.empty?
@@ -160,6 +207,7 @@ module Rigor
160
207
  return parse_diagnostics(path, parse_result)
161
208
  end
162
209
 
210
+ Effects::Collector.record_root(parse_result.value)
163
211
  scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
164
212
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
165
213
  # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
@@ -204,6 +252,14 @@ module Rigor
204
252
  @file_dependencies
205
253
  end
206
254
 
255
+ # ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
256
+ # configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
257
+ # Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
258
+ # coordinator, which folds them into the runner's collection exactly as the sequential path would.
259
+ def drain_effects
260
+ @file_effects
261
+ end
262
+
207
263
  private
208
264
 
209
265
  # Mirrors {Runner#seed_project_scope}: applies the cross-file pre-pass discovery tables the
@@ -159,6 +159,28 @@ module Rigor
159
159
  "the two corpora measured so far (Rigor's own `lib`, redmine `app/models`) it added no " \
160
160
  "kills — every surviving breakage there is one the analyzer reports nowhere at all, not one " \
161
161
  "it reports in a caller."
162
+ ),
163
+ # ADR-103 WD15 — the owner-ruled preview of the v0.4.0 default. `:behaviour` because it moves no rule's
164
+ # severity; safe under the header comment's cache-identity rule for a reason specific to this feature
165
+ # rather than the generic WD13 argument, so it is spelled out here: `effects:` collection is
166
+ # OBSERVATIONAL (ADR-103 WD13) and the `effects:` block itself is deliberately absent from the
167
+ # diagnostics cache-key identity, so whether this feature is adopted never changes what a `rigor check`
168
+ # diagnostics entry holds — only whether the (separately identity-keyed) effects sidecar slot
169
+ # (`Effects::Identity.descriptor`, `lib/rigor/analysis/runner.rb`) gets populated. Envelope findings
170
+ # (`effect.envelope-exceeded`) are recomputed every run from that sidecar and are never written into
171
+ # the diagnostics entry (ADR-103 WD12), so a warm diagnostics HIT is correct whichever way this feature
172
+ # is set, exactly like every other `:behaviour` feature. Verified against `Runner#compute_run_diagnostics`
173
+ # / `#serve_effect_collections` on 2026-08-17.
174
+ Feature.new(
175
+ id: "effects-on-by-default",
176
+ kind: :behaviour,
177
+ summary: "With it, a project whose `.rigor.yml` carries no `effects:` key at all behaves as though " \
178
+ "it had written `effects: {}` — effect collection, the `rigor effects` verbs' cache sharing, " \
179
+ "and `effects.check` all turn on with every sub-key at its default. Writing `effects: false` " \
180
+ "explicitly still opts out, and a project that writes its own `effects:` block keeps that " \
181
+ "block's shape untouched either way. Graduates to default-on at v0.4.0 (owner ruling, " \
182
+ "2026-08-17): ADR-50 § WD7 reads \"at a major\" as \"at v0.4.0\" for this feature on the 0.x " \
183
+ "evaluation line. See ADR-103 § WD15 for the preconditions that gate the flip."
162
184
  )
163
185
  ].freeze
164
186
 
@@ -51,8 +51,11 @@ module Rigor
51
51
  # nil (a clean cold rebuild — no migration). 11: ADR-67 WD6c lift adds `param_table` (the inferred-param
52
52
  # seed table the run's diagnostics were computed under, diffed on the next recheck to invalidate a
53
53
  # callee whose seeds moved because a caller changed); a pre-11 blob mismatches the SCHEMA gate and
54
- # loads as nil (a clean cold rebuild — no migration).
55
- SCHEMA = 11
54
+ # loads as nil (a clean cold rebuild — no migration). 12: ADR-103 WD13 / issue #382 adds the effects
55
+ # sidecar — `effect_collections` (the per-file {Rigor::Effects::FileCollection}s a collecting run
56
+ # produced) and the `effects_identity` they were produced under; a pre-12 blob mismatches the SCHEMA
57
+ # gate and loads as nil (a clean cold rebuild — no migration).
58
+ SCHEMA = 12
56
59
 
57
60
  # The persisted per-file state.
58
61
  # `cache` maps an analyzed file to its diagnostics.
@@ -87,10 +90,22 @@ module Rigor
87
90
  # recomputes the table fresh (the pre-pass is whole-project by design) and diffs it against this copy;
88
91
  # a changed entry invalidates the callee's file and its symbol dependents. The types are Marshal-clean
89
92
  # by the session's per-entry filter; a dropped entry re-checks its callee, the conservative direction.
93
+ # ADR-103 WD13 / issue #382 — the effects sidecar, ADR-46's half of "one cache, two identities, one
94
+ # extra slot":
95
+ # `effect_collections` maps an analyzed path to the {Rigor::Effects::FileCollection} that file
96
+ # contributed (`{}` when collection is off), so a recheck re-collects only the changed closure and
97
+ # serves the rest from here. The propagated table is NEVER stored — it is recomputed from the merged
98
+ # whole every run, because a leaf's summary reaches every caller and a stored table would have to be
99
+ # invalidated by all of them.
100
+ # `effects_identity` is {Rigor::Effects::Identity.digest} at the run that wrote them (nil when
101
+ # collection was off). It is a SEPARATE gate from the global `fingerprint`: the `effects:` block is
102
+ # deliberately absent from `Configuration#to_h`, so turning collection on invalidates no diagnostics,
103
+ # and a vocabulary / catalogue / `effects:` change must invalidate the summaries alone.
90
104
  Payload = Data.define(:cache, :sources, :digests, :analyzed,
91
105
  :symbol_sources, :ancestry_sources, :symbol_fingerprints,
92
106
  :missing, :class_decls, :seed_bundles, :plugin_fact_digest,
93
- :return_summaries, :param_table)
107
+ :return_summaries, :param_table,
108
+ :effect_collections, :effects_identity)
94
109
 
95
110
  # The global fingerprint that gates a snapshot load: a digest of the inputs whose change requires a full
96
111
  # rebuild — the engine version + schema, the engine's own SOURCE when the version does not pin it, the
@@ -206,7 +221,9 @@ module Rigor
206
221
  seed_bundles: data[:seed_bundles] || {},
207
222
  plugin_fact_digest: data[:plugin_fact_digest],
208
223
  return_summaries: data[:return_summaries] || {},
209
- param_table: data[:param_table] || {}
224
+ param_table: data[:param_table] || {},
225
+ effect_collections: data[:effect_collections] || {},
226
+ effects_identity: data[:effects_identity]
210
227
  )
211
228
  end
212
229
  private :payload_from
@@ -227,7 +244,9 @@ module Rigor
227
244
  seed_bundles: payload.seed_bundles,
228
245
  plugin_fact_digest: payload.plugin_fact_digest,
229
246
  return_summaries: payload.return_summaries,
230
- param_table: payload.param_table
247
+ param_table: payload.param_table,
248
+ effect_collections: payload.effect_collections,
249
+ effects_identity: payload.effects_identity
231
250
  )
232
251
  blob = Zlib::Deflate.deflate(raw)
233
252
  tmp = "#{@path}.#{Process.pid}.tmp"
@@ -459,6 +459,11 @@ module Rigor
459
459
  # key. `:unset` means "no flag — use the configured selection"; `true` adopts the whole overlay, `false`
460
460
  # adopts none, and an Array of ids adopts only those (see `apply_bleeding_edge_override`).
461
461
  bleeding_edge: :unset,
462
+ # ADR-103 WD1 / #385 — the effect-policy audit switch. Judges `effect.envelope-exceeded` as if
463
+ # `effects.tolerated:` were empty, so a project can see what its discharge policy is hiding
464
+ # without editing the policy. Judgment-time only: the run, its collection and its cache identity
465
+ # are identical either way.
466
+ no_tolerated_effects: false,
462
467
  # Type-precision coverage block. Off by default — it is a second precision pass over the analyzed files (the
463
468
  # same scan `rigor coverage` runs), so it is opt-in to keep the default check path's cost unchanged. When set,
464
469
  # `--format json` gains a `coverage` object (scan_files + precision tiers) and the text output prints a
@@ -528,6 +533,10 @@ module Rigor
528
533
  "ADR-50: ignore any configured bleeding_edge: selection for this run") do
529
534
  options[:bleeding_edge] = false
530
535
  end
536
+ opts.on("--no-tolerated-effects",
537
+ "ADR-103: check effect envelopes as if effects.tolerated: were empty") do
538
+ options[:no_tolerated_effects] = true
539
+ end
531
540
  end
532
541
  parser.parse!(@argv)
533
542
  options
@@ -558,9 +567,12 @@ module Rigor
558
567
 
559
568
  path = options.fetch(:config) || Configuration.discover
560
569
  data = path && File.exist?(path) ? Configuration.load_with_includes(path) : {}
570
+ # ADR-103 WD15 — captured before `DEFAULTS.merge` below folds "no `effects:` key" and "`effects:
571
+ # false`" into the same value; see `Configuration.load`'s identical capture.
572
+ effects_key_present = data.key?("effects")
561
573
  data = data.dup
562
574
  data["plugins"] = inject_treat_all_as_inline_rbs(Array(data["plugins"]))
563
- Configuration.new(Configuration::DEFAULTS.merge(data))
575
+ Configuration.new(Configuration::DEFAULTS.merge(data), effects_key_present)
564
576
  end
565
577
 
566
578
  # ADR-50 § WD2 — applies the `--bleeding-edge[=ids]` / `--no-bleeding-edge` CLI selection over the configured
@@ -35,7 +35,10 @@ module Rigor
35
35
  cache_store: cache_store,
36
36
  collect_stats: options.fetch(:stats),
37
37
  workers: resolve_workers(options, configuration),
38
- buffer: buffer
38
+ buffer: buffer,
39
+ # ADR-103 #385 — absent from `doctor`'s option hash and every other reuser's, so it defaults off
40
+ # for them without each having to declare a key about a feature they do not surface.
41
+ no_tolerated_effects: options.fetch(:no_tolerated_effects, false)
39
42
  )
40
43
  end
41
44
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../analysis/runner"
7
+ require_relative "../cache/store"
8
+ require_relative "command"
9
+ require_relative "effects_renderer"
10
+ require_relative "effects_report"
11
+ require_relative "effects_snapshot_command"
12
+ require_relative "options"
13
+
14
+ module Rigor
15
+ class CLI
16
+ # ADR-103 — executes `rigor effects`.
17
+ #
18
+ # Two surfaces behind one command, the shape `rigor baseline` already has:
19
+ #
20
+ # - `rigor effects [PATH…]` — the **report**. Runs the same analysis `rigor check` runs, with effect
21
+ # collection on, and prints the resulting summaries instead of the diagnostic stream.
22
+ # - `rigor effects {update,check,diff,explain}` — the committed **effect snapshot** and its drift
23
+ # gate, in {EffectsSnapshotCommand}.
24
+ #
25
+ # Neither emits a diagnostic nor enters `rigor check`'s stream; the report always exits 0, and only
26
+ # `check` ever exits non-zero (1 on drift, 64 on a usage error).
27
+ #
28
+ # The command turns collection on for its own run by loading the project's configuration and enabling
29
+ # an implicit `effects: {}` when the file carries no `effects:` block — the ad-hoc mode ADR-103 WD14
30
+ # describes. A project that *does* configure `effects:` gets its own settings instead, and then this
31
+ # run shares the whole cache with `rigor check`: the diagnostics come from the ADR-45 run entry and the
32
+ # summaries from the effects sidecar keyed beside it (#382), so `rigor effects` after `rigor check` in
33
+ # the same job is a warm hit plus the fixpoint. The ad-hoc mode digests a different `effects:` block
34
+ # than the project's, so it shares the diagnostics entry and keys its own effects slot.
35
+ class EffectsCommand < Command
36
+ USAGE = "Usage: rigor effects [options] [paths]"
37
+
38
+ # @return [Integer] CLI exit status: 0 on success, 1 on `check` drift, 64 on a usage error.
39
+ def run
40
+ verb = @argv.first
41
+ return run_verb(verb) if EffectsSnapshotCommand::VERBS.include?(verb)
42
+ return print_help if %w[help --help -h].include?(verb)
43
+
44
+ run_report
45
+ end
46
+
47
+ private
48
+
49
+ def run_verb(verb)
50
+ @argv.shift
51
+ EffectsSnapshotCommand.new(argv: @argv, verb: verb, out: @out, err: @err).run
52
+ end
53
+
54
+ def print_help
55
+ @out.puts(help)
56
+ 0
57
+ end
58
+
59
+ def help
60
+ <<~HELP
61
+ #{USAGE}
62
+
63
+ With no subcommand, prints one line per method: its proven effect labels and whether that
64
+ list is exhaustive.
65
+
66
+ Subcommands (the committed effect snapshot, ADR-103 WD7):
67
+ update Write the snapshot to effects.snapshot.path. Commit it; review its diff.
68
+ check Recompute and compare; exits 1 on drift, 0 when fresh.
69
+ diff The same comparison, never gating.
70
+ explain The shortest edge path behind a reach change (--symbol KEY for one unit).
71
+
72
+ Run `rigor effects <subcommand> --help` for subcommand options.
73
+ HELP
74
+ end
75
+
76
+ def run_report
77
+ options = parse_options
78
+ return usage_error("unsupported format: #{options.fetch(:format)}") unless FORMATS.include?(options[:format])
79
+
80
+ configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
81
+ table = analyze(configuration)
82
+ report = EffectsReport.build(table, full: options.fetch(:full))
83
+ EffectsRenderer.new(out: @out).render(report, format: options.fetch(:format))
84
+ 0
85
+ end
86
+
87
+ FORMATS = %w[text json].freeze
88
+ private_constant :FORMATS
89
+
90
+ def parse_options
91
+ options = { config: nil, format: "text", full: false, no_tolerated: false }
92
+ OptionParser.new do |opts|
93
+ opts.banner = USAGE
94
+ Options.add_config(opts, options)
95
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
96
+ opts.on("--full", "List every method, including exhaustive ones with no effects beyond mutate.local") do
97
+ options[:full] = true
98
+ end
99
+ # Accepted here and deliberately inert, exactly as it is on `update`: the report is an
100
+ # observation, and observations are undischarged. Only a JUDGMENT reads `effects.tolerated:` —
101
+ # `rigor effects check` / `diff`, and `rigor check`'s envelope contract.
102
+ opts.on("--no-tolerated-effects", "Judge as if effects.tolerated: were empty (inert on the report)") do
103
+ options[:no_tolerated] = true
104
+ end
105
+ end.parse!(@argv)
106
+ options
107
+ end
108
+
109
+ def usage_error(message)
110
+ @err.puts(message)
111
+ @err.puts(USAGE)
112
+ CLI::EXIT_USAGE
113
+ end
114
+
115
+ # Sequential and cache-backed, through the same ADR-45 whole-run result cache `rigor check` uses:
116
+ # the diagnostics entry serves the run and the #382 effects sidecar serves the collections, leaving
117
+ # only the fixpoint. Sequential is not a cache decision — the run-result cache declines pool mode —
118
+ # but a collecting run is pinned to the fork backend anyway, so `workers: 0` costs nothing here.
119
+ def analyze(configuration)
120
+ runner = Analysis::Runner.new(
121
+ configuration: configuration,
122
+ cache_store: Cache::Store.new(root: configuration.cache_path),
123
+ collect_stats: false,
124
+ workers: 0
125
+ )
126
+ runner.run(@argv.empty? ? configuration.paths : @argv)
127
+ runner.effect_table
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../effects/snapshot_diff"
6
+ require_relative "renderable"
7
+
8
+ module Rigor
9
+ class CLI
10
+ # Prints a {Rigor::Effects::SnapshotDiff} — what `rigor effects check` and `rigor effects diff` show a
11
+ # reviewer (ADR-103 WD7).
12
+ #
13
+ # The text form is grouped by table and then by policy: real drift under `methods:` / `reach:`,
14
+ # policy-discharged drift under `tolerated:`, and a regeneration event — a record written by a
15
+ # different Rigor, vocabulary or `effects:` block — under `regeneration:` above both. It closes with
16
+ # the one line the whole workflow turns on: intent is expressed by regenerating and committing the
17
+ # file, not by annotating the code.
18
+ #
19
+ # It is deliberately not a diagnostic format: no severities, no positions, no exit-code weight of its
20
+ # own. The exit code comes from the gate.
21
+ class EffectsDiffRenderer
22
+ include Renderable
23
+
24
+ # How each category renders as the marker after the symbol. Steins' event vocabulary, made
25
+ # symmetric: `≤` is the declared lane, and a hedged removal says why it is hedged.
26
+ MARKERS = {
27
+ Effects::SnapshotDiff::LABEL_ADDED => "+ %<label>s",
28
+ Effects::SnapshotDiff::LABEL_REMOVED => "- %<label>s",
29
+ Effects::SnapshotDiff::DECLARED_ADDED => "≤+ %<label>s",
30
+ Effects::SnapshotDiff::DECLARED_REMOVED => "≤- %<label>s",
31
+ Effects::SnapshotDiff::MATERIALISED => "materialised %<label>s (declared → proven)",
32
+ Effects::SnapshotDiff::SYMBOL_ADDED => "+symbol %<detail>s",
33
+ Effects::SnapshotDiff::SYMBOL_REMOVED => "-symbol %<detail>s"
34
+ }.freeze
35
+
36
+ HEDGED_REMOVAL = "-? %<label>s (current summary is not exhaustive)"
37
+
38
+ CLOSING_LINE = "Run `rigor effects update` and commit the result if this change is intended."
39
+
40
+ def initialize(out:, path:)
41
+ @out = out
42
+ @path = path
43
+ end
44
+
45
+ private
46
+
47
+ def render_text(diff)
48
+ if diff.fresh?
49
+ @out.puts("No effect drift against #{@path}.")
50
+ return
51
+ end
52
+
53
+ @out.puts("Effect drift against #{@path}:")
54
+ %w[snapshot header methods reach].each { |table| render_section(table, diff.events_for(table)) }
55
+ render_section("tolerated", diff.tolerated_events, qualified: true)
56
+ render_footer(diff)
57
+ end
58
+
59
+ # `qualified:` names each event's table inline, which the `tolerated:` heading needs: it pools events
60
+ # from both tables, and one symbol commonly appears in each.
61
+ def render_section(table, events, qualified: false)
62
+ return if events.empty?
63
+
64
+ @out.puts("")
65
+ @out.puts("#{section_name(table)}:")
66
+ events.each do |event|
67
+ suffix = qualified ? " (#{event.table})" : ""
68
+ @out.puts(" #{render_event(event)}#{suffix}")
69
+ end
70
+ end
71
+
72
+ def section_name(table)
73
+ case table
74
+ when "header" then "regeneration"
75
+ when "snapshot" then "snapshot"
76
+ else table
77
+ end
78
+ end
79
+
80
+ def render_event(event)
81
+ marker = marker_for(event)
82
+ event.symbol.nil? ? marker : "#{event.symbol} #{marker}"
83
+ end
84
+
85
+ def marker_for(event)
86
+ return format(HEDGED_REMOVAL, label: event.label) if
87
+ event.category == Effects::SnapshotDiff::LABEL_REMOVED && event.hedged?
88
+
89
+ template = MARKERS[event.category]
90
+ return event.detail.to_s unless template
91
+
92
+ format(template, label: event.label.to_s, detail: event.detail.to_s)
93
+ end
94
+
95
+ def render_footer(diff)
96
+ footer = diff.footer
97
+ @out.puts("")
98
+ unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
99
+ @out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
100
+ "(a rename is one of each)")
101
+ end
102
+ @out.puts(CLOSING_LINE)
103
+ end
104
+
105
+ def render_json(diff)
106
+ @out.puts(JSON.pretty_generate(
107
+ "fresh" => diff.fresh?,
108
+ "events" => diff.events.map(&:to_h),
109
+ "footer" => {
110
+ "added_symbols" => diff.footer[:added_symbols],
111
+ "removed_symbols" => diff.footer[:removed_symbols]
112
+ },
113
+ "header" => {
114
+ "snapshot" => @path,
115
+ "gate" => diff.gate.to_s,
116
+ "recorded" => diff.recorded&.header,
117
+ "current" => diff.current.header
118
+ }
119
+ ))
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "renderable"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Prints what `rigor effects explain` found: for a `reach:` change, the shortest edge path from the
10
+ # entry point to the origin that introduced the label; for a `methods:` change, the origin itself
11
+ # (ADR-103 WD7).
12
+ #
13
+ # reach:
14
+ # OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
15
+ # methods:
16
+ # PaymentGateway#charge [io.net.http] ← catalogue:Net::HTTP.get
17
+ #
18
+ # A `methods:` change has no path to walk — the label came from this method's own body — so what
19
+ # explains it is the origin: the catalogue row or language construct, and which of the two it was.
20
+ # Origins are line-free by design (a summary must be stable under a line move), so no position is
21
+ # printed; call sites are per-run data the report carries.
22
+ class EffectsExplainRenderer
23
+ include Renderable
24
+
25
+ # One printed explanation. `path` is empty for a `methods:` row, whose explanation is the origin.
26
+ Row = Data.define(:table, :symbol, :label, :path, :origin)
27
+
28
+ def initialize(out:)
29
+ @out = out
30
+ end
31
+
32
+ private
33
+
34
+ def render_text(rows)
35
+ if rows.empty?
36
+ @out.puts("Nothing to explain.")
37
+ return
38
+ end
39
+
40
+ rows.group_by(&:table).each do |table, group|
41
+ @out.puts("#{table}:")
42
+ group.each { |row| @out.puts(" #{render_row(row)}") }
43
+ end
44
+ end
45
+
46
+ def render_row(row)
47
+ return "#{row.path.join(' → ')} [#{row.label}]" unless row.path.empty?
48
+
49
+ origin = row.origin ? " ← #{row.origin}" : ""
50
+ "#{row.symbol} [#{row.label}]#{origin}"
51
+ end
52
+
53
+ def render_json(rows)
54
+ @out.puts(JSON.pretty_generate(
55
+ "paths" => rows.map do |row|
56
+ {
57
+ "table" => row.table,
58
+ "symbol" => row.symbol,
59
+ "label" => row.label,
60
+ "path" => row.path,
61
+ "origin" => row.origin
62
+ }
63
+ end
64
+ ))
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "renderable"
6
+
7
+ module Rigor
8
+ class CLI
9
+ # Prints an {EffectsReport}. Text is the review surface; JSON is what a bot reads.
10
+ #
11
+ # The text form says three things per method and nothing else: what it is proven to do, what a source
12
+ # Rigor trusts merely *claims* it does (`≤`, the declared lane), and whether the list is complete. The
13
+ # `…?` suffix is the exhaustiveness bit — "these effects, and possibly more" — and the indented lines
14
+ # under it are the closed-enum causes behind it. Deliberately not a diagnostic format: no severities,
15
+ # no positions, no exit-code weight.
16
+ class EffectsRenderer
17
+ include Renderable
18
+
19
+ def initialize(out:)
20
+ @out = out
21
+ end
22
+
23
+ private
24
+
25
+ def render_text(report)
26
+ report.rows.each do |row|
27
+ @out.puts("#{row.key}: [#{row.effects.join(', ')}]#{declared(row)}#{' …?' unless row.exhaustive?}")
28
+ row.causes.each { |cause, detail| @out.puts(" #{cause}#{" (#{detail})" if detail}") }
29
+ end
30
+ end
31
+
32
+ # `≤` is the lane's spelling everywhere in the model — an upper bound, not an observation — so the
33
+ # report writes it rather than inventing a second word for it.
34
+ def declared(row)
35
+ row.declared.empty? ? "" : " ≤ [#{row.declared.join(', ')}]"
36
+ end
37
+
38
+ def render_json(report)
39
+ payload = {
40
+ "methods" => report.rows.to_h do |row|
41
+ [row.key, {
42
+ "effects" => row.effects,
43
+ "declared" => row.declared,
44
+ "exhaustive" => row.exhaustive?,
45
+ "causes" => row.causes.map { |cause, detail| [cause, detail] },
46
+ "direct" => row.direct
47
+ }]
48
+ end
49
+ }
50
+ @out.puts(JSON.pretty_generate(payload))
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # The value `EffectsCommand` assembles and `EffectsRenderer` prints — one row per effect unit, already
6
+ # projected out of {Rigor::Effects::EffectTable} so the renderer never touches an engine value.
7
+ #
8
+ # Rows are sorted by key and every collection inside one is sorted, so two runs over the same tree
9
+ # print byte-identical output whether analysis ran sequentially or across the fork pool.
10
+ class EffectsReport < Data.define(:rows, :full)
11
+ # One method's line in the report.
12
+ #
13
+ # `effects` is the transitive proven lane — this method's labels joined with every project method it
14
+ # reaches. `exhaustive` false reads "these effects, and possibly more", and `causes` says why.
15
+ # `direct` is what this method's own body contributed, per origin, which is the attributable half a
16
+ # snapshot records (#381).
17
+ #
18
+ # `declared` is the `≤` lane, transitive like `effects`: what a source Rigor trusts but did not
19
+ # verify *claims* this method reaches — today the project's `effects.attribution:` table (#385). It
20
+ # is printed apart from `effects` and never folded into it, because the two answer different
21
+ # questions: one is proven, the other is asserted. A declared label the proven lane already admits
22
+ # is dropped here, where output is rendered; the table keeps both lanes raw.
23
+ class Row < Data.define(:key, :effects, :declared, :exhaustive, :causes, :direct)
24
+ def exhaustive?
25
+ exhaustive
26
+ end
27
+ end
28
+
29
+ # Builds a report from an effect table. `full:` keeps the rows the report otherwise omits — an
30
+ # exhaustive method proving nothing beyond `mutate.local`, which is the reading of `%a{pure}`.
31
+ def self.build(table, full: false)
32
+ rows = table.filter_map { |entry| row_for(entry) unless !full && entry.trivial? }
33
+ new(rows: rows.freeze, full: full)
34
+ end
35
+
36
+ def self.row_for(entry)
37
+ Row.new(
38
+ key: entry.key,
39
+ effects: entry.proven.to_a,
40
+ declared: entry.rendered_declared.to_a,
41
+ exhaustive: entry.exhaustive?,
42
+ causes: entry.causes,
43
+ direct: entry.direct.bundles.to_h { |origin, labels| [origin.to_s, labels.to_a] }.freeze
44
+ )
45
+ end
46
+ private_class_method :row_for
47
+
48
+ def empty?
49
+ rows.empty?
50
+ end
51
+ end
52
+ end
53
+ end