rigortype 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -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,52 @@ 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,
191
+ superclasses: @project_scope_seed[:discovered_superclasses] || {},
192
+ includes: @project_scope_seed[:discovered_includes] || {}
193
+ )
194
+ end
195
+ private :effect_plugin_facts
196
+
197
+ def effect_envelope_index
198
+ @effect_envelope_index ||= Effects::EnvelopeIndex.build(
199
+ configuration: @configuration, environment: @environment, plugin_facts: effect_plugin_facts
200
+ )
201
+ end
202
+ private :effect_envelope_index
203
+
155
204
  def analyze_body(path)
156
205
  parse_result = parse_source(path)
157
206
  unless parse_result.errors.empty?
@@ -160,6 +209,7 @@ module Rigor
160
209
  return parse_diagnostics(path, parse_result)
161
210
  end
162
211
 
212
+ Effects::Collector.record_root(parse_result.value)
163
213
  scope = seed_project_scope(Scope.empty(environment: @environment, source_path: path))
164
214
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
165
215
  # ADR-53 B4 — built-in collectors + plugin node rules share one walk.
@@ -204,6 +254,14 @@ module Rigor
204
254
  @file_dependencies
205
255
  end
206
256
 
257
+ # ADR-103 WD12 — the per-file {Effects::FileCollection}s this session collected (empty unless the
258
+ # configuration carries an `effects:` block). Marshal-clean by construction — frozen Hashes of
259
+ # Strings, `LabelSet`s of frozen Arrays, and `Data` edges — so the fork pool ships them back to the
260
+ # coordinator, which folds them into the runner's collection exactly as the sequential path would.
261
+ def drain_effects
262
+ @file_effects
263
+ end
264
+
207
265
  private
208
266
 
209
267
  # 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,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+
5
+ require_relative "../configuration"
6
+ require_relative "../analysis/runner"
7
+ require_relative "../effects/plugin_facts"
8
+ require_relative "../effects/registry"
9
+ require_relative "../plugin/loader"
10
+ require_relative "../cache/store"
11
+ require_relative "command"
12
+ require_relative "effects_renderer"
13
+ require_relative "effects_report"
14
+ require_relative "effects_snapshot_command"
15
+ require_relative "options"
16
+
17
+ module Rigor
18
+ class CLI
19
+ # ADR-103 — executes `rigor effects`.
20
+ #
21
+ # Two surfaces behind one command, the shape `rigor baseline` already has:
22
+ #
23
+ # - `rigor effects [PATH…]` — the **report**. Runs the same analysis `rigor check` runs, with effect
24
+ # collection on, and prints the resulting summaries instead of the diagnostic stream.
25
+ # - `rigor effects {update,check,diff,explain}` — the committed **effect snapshot** and its drift
26
+ # gate, in {EffectsSnapshotCommand}.
27
+ #
28
+ # Neither emits a diagnostic nor enters `rigor check`'s stream; the report always exits 0, and only
29
+ # `check` ever exits non-zero (1 on drift, 64 on a usage error).
30
+ #
31
+ # The command turns collection on for its own run by loading the project's configuration and enabling
32
+ # an implicit `effects: {}` when the file carries no `effects:` block — the ad-hoc mode ADR-103 WD14
33
+ # describes. A project that *does* configure `effects:` gets its own settings instead, and then this
34
+ # run shares the whole cache with `rigor check`: the diagnostics come from the ADR-45 run entry and the
35
+ # summaries from the effects sidecar keyed beside it (#382), so `rigor effects` after `rigor check` in
36
+ # the same job is a warm hit plus the fixpoint. The ad-hoc mode digests a different `effects:` block
37
+ # than the project's, so it shares the diagnostics entry and keys its own effects slot.
38
+ class EffectsCommand < Command
39
+ USAGE = "Usage: rigor effects [options] [paths]"
40
+
41
+ # @return [Integer] CLI exit status: 0 on success, 1 on `check` drift, 64 on a usage error.
42
+ def run
43
+ verb = @argv.first
44
+ return run_verb(verb) if EffectsSnapshotCommand::VERBS.include?(verb)
45
+ return print_help if %w[help --help -h].include?(verb)
46
+
47
+ run_report
48
+ end
49
+
50
+ private
51
+
52
+ def run_verb(verb)
53
+ @argv.shift
54
+ EffectsSnapshotCommand.new(argv: @argv, verb: verb, out: @out, err: @err).run
55
+ end
56
+
57
+ def print_help
58
+ @out.puts(help)
59
+ 0
60
+ end
61
+
62
+ def help
63
+ <<~HELP
64
+ #{USAGE}
65
+
66
+ With no subcommand, prints one line per method: its proven effect labels and whether that
67
+ list is exhaustive. A PATH selects which methods are printed, never which are analysed.
68
+
69
+ Options:
70
+ --config=PATH Path to the Rigor configuration file
71
+ --format=FORMAT Output format: text (default) or json
72
+ --full List every method, including the ones with nothing to say
73
+ --label=LABEL Only methods carrying LABEL (or a label under it), in either lane
74
+ --pure Only methods proven to do nothing beyond mutate.local
75
+ --limit=N Print at most N methods
76
+ --why Expand each method's unresolved reasons and declared-lane sources
77
+ --list-labels Print the effect vocabulary this project can name, and exit
78
+
79
+ Subcommands (the committed effect snapshot, ADR-103 WD7):
80
+ update Write the snapshot to effects.snapshot.path. Commit it; review its diff.
81
+ check Recompute and compare; exits 1 on drift, 0 when fresh.
82
+ diff The same comparison, never gating.
83
+ explain The shortest edge path behind a reach change (--symbol KEY for one unit).
84
+
85
+ Run `rigor effects <subcommand> --help` for subcommand options.
86
+ HELP
87
+ end
88
+
89
+ def run_report
90
+ options = parse_options
91
+ return usage_error("unsupported format: #{options.fetch(:format)}") unless FORMATS.include?(options[:format])
92
+
93
+ configuration = Configuration.load(options.fetch(:config)).with_effects_enabled
94
+ return list_labels(configuration) if options.fetch(:list_labels)
95
+
96
+ scope = @argv.dup
97
+ table, sources = analyze(configuration, scope)
98
+ report = EffectsReport.build(
99
+ table, full: options.fetch(:full), sources: sources, scope: scope,
100
+ label: options.fetch(:label), pure: options.fetch(:pure), limit: options.fetch(:limit)
101
+ )
102
+ note_scope(scope, report, table)
103
+ EffectsRenderer.new(out: @out, why: options.fetch(:why)).render(report, format: options.fetch(:format))
104
+ 0
105
+ end
106
+
107
+ # A path argument is a **view**, and the note says so (#439).
108
+ #
109
+ # It used to narrow the analysed set, and effect labels are transitive over whatever was analysed —
110
+ # so `rigor effects app/controllers/issues_controller.rb` reported `IssuesController#create: [] …?`
111
+ # where the whole-project run reported four labels and a declared lane. The weakened answer was
112
+ # indistinguishable from a genuinely effect-free method, and a path argument is the only tractability
113
+ # lever the report has, so it was the thing an adopter reached for first.
114
+ #
115
+ # The note goes to stderr rather than into the report: it is about the invocation, not about the
116
+ # code, and `--format json` and `rigor effects … > report.txt` both stay exactly what they were.
117
+ def note_scope(scope, report, table)
118
+ return if scope.empty?
119
+
120
+ if report.empty?
121
+ @err.puts("rigor: no effect unit is defined in #{scope.join(', ')} " \
122
+ "(a path selects what is printed, not what is analysed)")
123
+ return
124
+ end
125
+
126
+ @err.puts("rigor: showing #{report.rows.length} of #{table.size} units, selected by " \
127
+ "#{scope.join(', ')}; a path narrows the printing and not the analysis, so every " \
128
+ "label is the one the whole-project run reports")
129
+ end
130
+
131
+ FORMATS = %w[text json].freeze
132
+ private_constant :FORMATS
133
+
134
+ def parse_options
135
+ options = { config: nil, format: "text", full: false, no_tolerated: false, label: [], pure: false,
136
+ limit: nil, why: false, list_labels: false }
137
+ OptionParser.new do |opts|
138
+ opts.banner = USAGE
139
+ Options.add_config(opts, options)
140
+ opts.on("--format=FORMAT", "Output format: text (default) or json") { |value| options[:format] = value }
141
+ opts.on("--full", "List every method, including the ones with nothing to say") do
142
+ options[:full] = true
143
+ end
144
+ opts.on("--label=LABEL", "Only methods carrying LABEL (or a label under it), in either lane") do |value|
145
+ options[:label].concat(value.split(",").map(&:strip).reject(&:empty?))
146
+ end
147
+ opts.on("--pure", "Only methods proven to do nothing beyond mutate.local — the %a{pure} set") do
148
+ options[:pure] = true
149
+ end
150
+ opts.on("--limit=N", Integer, "Print at most N methods") { |value| options[:limit] = value }
151
+ opts.on("--why", "Expand each method's unresolved reasons and declared-lane sources") do
152
+ options[:why] = true
153
+ end
154
+ opts.on("--list-labels", "Print the effect vocabulary this project can name, and exit") do
155
+ options[:list_labels] = true
156
+ end
157
+ # Accepted here and deliberately inert, exactly as it is on `update`: the report is an
158
+ # observation, and observations are undischarged. Only a JUDGMENT reads `effects.tolerated:` —
159
+ # `rigor effects check` / `diff`, and `rigor check`'s envelope contract.
160
+ opts.on("--no-tolerated-effects", "Judge as if effects.tolerated: were empty (inert on the report)") do
161
+ options[:no_tolerated] = true
162
+ end
163
+ end.parse!(@argv)
164
+ options
165
+ end
166
+
167
+ def usage_error(message)
168
+ @err.puts(message)
169
+ @err.puts(USAGE)
170
+ CLI::EXIT_USAGE
171
+ end
172
+
173
+ # Sequential and cache-backed, through the same ADR-45 whole-run result cache `rigor check` uses:
174
+ # the diagnostics entry serves the run and the #382 effects sidecar serves the collections, leaving
175
+ # only the fixpoint. Sequential is not a cache decision — the run-result cache declines pool mode —
176
+ # but a collecting run is pinned to the fork backend anyway, so `workers: 0` costs nothing here.
177
+ # #429 — the vocabulary, from the product rather than from a document the gem does not ship.
178
+ #
179
+ # Four configuration keys and two annotation forms all require typing an effect label, and until
180
+ # this there was no way to find out what the labels are: the manual's pointers resolve into
181
+ # `docs/type-specification/`, which `rigortype.gemspec` does not package. The registry is shipped
182
+ # data and already knows the answer; nothing surfaced it.
183
+ #
184
+ # It loads plugins but never analyses, because a plugin is where `rails.flash.write` comes from and
185
+ # a reader deciding what to write in `effects.envelopes:` needs to see it. A plugin that fails to
186
+ # load costs its labels and nothing else — `rigor plugins` is where that failure is reported.
187
+ def list_labels(configuration)
188
+ registry = project_registry(configuration)
189
+ @out.puts("Effect vocabulary — #{registry.labels.length} labels (vocabulary #{registry.vocabulary_version})")
190
+ registry.roots.each do |root|
191
+ @out.puts
192
+ @out.puts(root_heading(root, registry))
193
+ in_root = registry.labels.select { |label| Effects::Label.root(label) == root }
194
+ in_root.each_slice(4) { |slice| @out.puts(" #{slice.join(', ')}") }
195
+ end
196
+ @out.puts
197
+ @out.puts("A bound naming a label admits everything under it and nothing above it: `io` covers " \
198
+ "`io.db.read`, `io.db.read` does not cover `io`.")
199
+ 0
200
+ end
201
+
202
+ # A shipped root carries the registry's own one-liner; a root a plugin or this project opened
203
+ # carries where it came from instead, which is the more useful thing to say about it.
204
+ def root_heading(root, registry)
205
+ description = registry.descriptions[root]
206
+ return "#{root} — #{description}" if description
207
+
208
+ "#{root} — opened by a plugin or by this project's `effects.labels:`"
209
+ end
210
+
211
+ def project_registry(configuration)
212
+ services = Plugin::Services.new(reflection: Reflection, type: Type::Combinator,
213
+ configuration: configuration, cache_store: nil)
214
+ plugins = Plugin::Loader.load(configuration: configuration, services: services)
215
+ Effects::Registry.for_configuration(configuration, plugin_facts: Effects::PluginFacts.build(plugins))
216
+ rescue StandardError
217
+ Effects::Registry.for_configuration(configuration)
218
+ end
219
+
220
+ # The analysed set is the configured `paths:` **plus** whatever the arguments name — never the
221
+ # arguments alone (#439). An effect summary is transitive over whatever was analysed, so analysing
222
+ # less does not filter the report, it lowers every answer in it: `rigor effects
223
+ # app/controllers/issues_controller.rb` used to report `IssuesController#create: [] …?` where the
224
+ # whole-project run reported four labels and a declared lane, with nothing marking the difference.
225
+ #
226
+ # The union rather than the configured paths alone, so that pointing the command at a tree the
227
+ # configuration does not cover — which is what every `rigor effects PATH` invocation from outside a
228
+ # project does — still analyses it. Inside a project the argument is already under `paths:` and the
229
+ # union is the configured set unchanged.
230
+ #
231
+ # @return [Array(Rigor::Effects::EffectTable, Hash{String=>Array<String>})] the table, and which
232
+ # file each unit was defined in — the map {EffectsReport} needs to answer a path argument.
233
+ def analyze(configuration, scope)
234
+ runner = Analysis::Runner.new(
235
+ configuration: configuration,
236
+ cache_store: Cache::Store.new(root: configuration.cache_path),
237
+ collect_stats: false,
238
+ workers: 0
239
+ )
240
+ runner.run((configuration.paths + scope).uniq)
241
+ [runner.effect_table, runner.effect_sources]
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,127 @@
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
+ # Both commands, because they answer the two questions a drift report raises and a reader almost
39
+ # always asks them in this order (#435). `explain` is the one the manual's own narrative reaches for
40
+ # first, and the footer used to name only the one that makes the report go away.
41
+ CLOSING_LINE = "Run `rigor effects explain` to see what caused this, and `rigor effects update` to " \
42
+ "accept it."
43
+
44
+ def initialize(out:, path:)
45
+ @out = out
46
+ @path = path
47
+ end
48
+
49
+ private
50
+
51
+ def render_text(diff)
52
+ if diff.fresh?
53
+ @out.puts("No effect drift against #{@path}.")
54
+ return
55
+ end
56
+
57
+ @out.puts("Effect drift against #{@path}:")
58
+ %w[snapshot header methods reach].each { |table| render_section(table, diff.events_for(table)) }
59
+ render_section("tolerated", diff.tolerated_events, qualified: true)
60
+ render_footer(diff)
61
+ end
62
+
63
+ # `qualified:` names each event's table inline, which the `tolerated:` heading needs: it pools events
64
+ # from both tables, and one symbol commonly appears in each.
65
+ def render_section(table, events, qualified: false)
66
+ return if events.empty?
67
+
68
+ @out.puts("")
69
+ @out.puts("#{section_name(table)}:")
70
+ events.each do |event|
71
+ suffix = qualified ? " (#{event.table})" : ""
72
+ @out.puts(" #{render_event(event)}#{suffix}")
73
+ end
74
+ end
75
+
76
+ def section_name(table)
77
+ case table
78
+ when "header" then "regeneration"
79
+ when "snapshot" then "snapshot"
80
+ else table
81
+ end
82
+ end
83
+
84
+ def render_event(event)
85
+ marker = marker_for(event)
86
+ event.symbol.nil? ? marker : "#{event.symbol} #{marker}"
87
+ end
88
+
89
+ def marker_for(event)
90
+ return format(HEDGED_REMOVAL, label: event.label) if
91
+ event.category == Effects::SnapshotDiff::LABEL_REMOVED && event.hedged?
92
+
93
+ template = MARKERS[event.category]
94
+ return event.detail.to_s unless template
95
+
96
+ format(template, label: event.label.to_s, detail: event.detail.to_s)
97
+ end
98
+
99
+ def render_footer(diff)
100
+ footer = diff.footer
101
+ @out.puts("")
102
+ unless footer[:added_symbols].zero? && footer[:removed_symbols].zero?
103
+ @out.puts("symbols: +#{footer[:added_symbols]} / -#{footer[:removed_symbols]} " \
104
+ "(a rename is one of each)")
105
+ end
106
+ @out.puts(CLOSING_LINE)
107
+ end
108
+
109
+ def render_json(diff)
110
+ @out.puts(JSON.pretty_generate(
111
+ "fresh" => diff.fresh?,
112
+ "events" => diff.events.map(&:to_h),
113
+ "footer" => {
114
+ "added_symbols" => diff.footer[:added_symbols],
115
+ "removed_symbols" => diff.footer[:removed_symbols]
116
+ },
117
+ "header" => {
118
+ "snapshot" => @path,
119
+ "gate" => diff.gate.to_s,
120
+ "recorded" => diff.recorded&.header,
121
+ "current" => diff.current.header
122
+ }
123
+ ))
124
+ end
125
+ end
126
+ end
127
+ end