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
@@ -6,6 +6,10 @@ require_relative "bleeding_edge"
6
6
  require_relative "ci_detector"
7
7
  require_relative "configuration/dependencies"
8
8
  require_relative "configuration/severity_profile"
9
+ require_relative "configuration_error"
10
+ require_relative "effects/entry_points"
11
+ require_relative "effects/label"
12
+ require_relative "effects/method_key"
9
13
 
10
14
  module Rigor
11
15
  class Configuration # rubocop:disable Metrics/ClassLength
@@ -22,6 +26,15 @@ module Rigor
22
26
  # fixture paths still work; the discovery list is the canonical source.
23
27
  DEFAULT_PATH = DISCOVERY_ORDER.first
24
28
 
29
+ # ADR-103 WD7 — where `rigor effects update` writes when `effects.snapshot.path:` is unset. Public
30
+ # because the schema pins its own `default` against it.
31
+ DEFAULT_EFFECTS_SNAPSHOT_PATH = ".rigor-effects.yml"
32
+
33
+ # ADR-103 WD15 — the {BleedingEdge::FEATURES} id that, when adopted, makes a loaded file with no
34
+ # `effects:` key behave as `effects: {}`. See {#coerce_effects}.
35
+ EFFECTS_ON_BY_DEFAULT_FEATURE_ID = "effects-on-by-default"
36
+ private_constant :EFFECTS_ON_BY_DEFAULT_FEATURE_ID
37
+
25
38
  # Built-in exclusion patterns appended to `exclude:` so vendored dependencies, Bundler artefacts, and
26
39
  # JavaScript node_modules are never analysed by accident when a directory glob expands. Users cannot
27
40
  # disable these defaults; the trade-off is that analysing any of these paths is essentially never what
@@ -65,6 +78,24 @@ module Rigor
65
78
  # lifted): the incremental session recomputes the table each run and diffs it against its snapshot,
66
79
  # re-checking any callee whose seeds moved.
67
80
  "parameter_inference" => false,
81
+ # ADR-103 WD13 — the effect-labels opt-in, and the ONLY thing that turns effect collection on
82
+ # besides running `rigor effects`. **Presence is the switch**: `effects: {}` — or the bare key, which
83
+ # YAML parses as nil — enables collection with every sub-key at its default, because an annotation in
84
+ # a project's RBS must never create a project-wide cost cliff (that case earns a `:info` residual
85
+ # instead, #384).
86
+ #
87
+ # The default is `false` rather than `nil` precisely because presence is the switch and
88
+ # `Configuration.load` merges these DEFAULTS UNDER the loaded file: a `nil` default would be
89
+ # indistinguishable from a user's bare `effects:` key and would turn collection on for every project
90
+ # in existence. `false` is also the explicit-disable form a `.rigor.yml` uses to override an upstream
91
+ # `.rigor.dist.yml`'s `effects:` block — the same shape `baseline:` uses.
92
+ #
93
+ # The sub-keys are declared in `schemas/rigor-config.schema.json`. `check` (#383,
94
+ # {#effects_check?}), `snapshot.{path,reach,gate}`, `tolerated` (#381) and the policy trio
95
+ # `labels` / `attribution` / `envelopes` (#385, {#coerce_effects_policy}) are read; `views` lands
96
+ # with the slice that implements it (#390). Reserving a sub-key's shape before a reader exists is
97
+ # the same discipline the schema applies to a sibling implementation's namespace (ADR-99).
98
+ "effects" => false,
68
99
  "cache" => {
69
100
  "path" => ".rigor/cache",
70
101
  # LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
@@ -211,7 +242,27 @@ module Rigor
211
242
  :dependencies, :parallel_workers,
212
243
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
213
244
  :rbs_collection_lockfile, :rbs_collection_auto_detect,
214
- :pre_eval, :baseline_path
245
+ :pre_eval, :baseline_path, :effects,
246
+ :effects_snapshot_path, :effects_snapshot_reach, :effects_snapshot_gate, :effects_tolerated,
247
+ :effects_labels, :effects_attribution, :effects_envelopes
248
+
249
+ # ADR-103 WD13 — whether effect collection runs. True exactly when the loaded configuration carried an
250
+ # `effects:` block, whatever its body; `rigor effects` enables it for its own run by loading an
251
+ # implicit `effects: {}` instead of by consulting anything else. Nothing else — no annotation, no
252
+ # plugin, no severity profile — can turn collection on.
253
+ def effects_enabled?
254
+ !@effects.nil?
255
+ end
256
+
257
+ # ADR-103 WD14 / #383 — whether author-declared effect envelopes (`%a{pure}`,
258
+ # `%a{rigor:v1:effect …}`) are checked against the collected summaries, surfacing
259
+ # `effect.envelope-exceeded`. Defaults to **true when the `effects:` block is present**: the block is
260
+ # the opt-in, and a project that asked for effects asked for its own declarations to hold. An explicit
261
+ # `check: false` disables the diagnostic and leaves collection — and therefore `rigor effects` and the
262
+ # snapshot — untouched. Always false without the block, so an annotation alone changes nothing.
263
+ def effects_check?
264
+ @effects_check
265
+ end
215
266
 
216
267
  # Loads a configuration file.
217
268
  #
@@ -226,13 +277,19 @@ module Rigor
226
277
  # [PHPStan](https://phpstan.org/config-reference#paths).
227
278
  def self.load(path = nil)
228
279
  resolved = path || discover
229
- data =
230
- if resolved.nil? || !File.exist?(resolved)
231
- DEFAULTS
232
- else
233
- DEFAULTS.merge(load_with_includes(resolved))
234
- end
235
- new(autowire_default_plugins(data))
280
+ if resolved.nil? || !File.exist?(resolved)
281
+ data = DEFAULTS
282
+ effects_key_present = false
283
+ else
284
+ # ADR-103 WD15 — captured from the RAW, pre-`DEFAULTS.merge` file (+ its `includes:` chain) because
285
+ # `DEFAULTS` itself carries an `"effects" => false` entry: once merged, "the file never wrote
286
+ # `effects:`" and "the file wrote `effects: false`" collapse to the same value and become
287
+ # indistinguishable to {#initialize}. This is the only place that distinction still exists.
288
+ raw = load_with_includes(resolved)
289
+ effects_key_present = raw.key?("effects")
290
+ data = DEFAULTS.merge(raw)
291
+ end
292
+ new(autowire_default_plugins(data), effects_key_present)
236
293
  end
237
294
 
238
295
  # ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
@@ -302,10 +359,10 @@ module Rigor
302
359
  # {CLI::CheckCommand#load_check_configuration}).
303
360
  def self.load_with_includes(path, visited: Set.new)
304
361
  absolute = File.expand_path(path)
305
- raise ArgumentError, "circular include: #{absolute}" if visited.include?(absolute)
362
+ raise ConfigurationError, "circular include: #{absolute}" if visited.include?(absolute)
306
363
 
307
- raw = YAML.safe_load_file(absolute, aliases: false) || {}
308
- raise ArgumentError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
364
+ raw = read_yaml(absolute)
365
+ raise ConfigurationError, "config file must be a YAML mapping: #{absolute}" unless raw.is_a?(Hash)
309
366
 
310
367
  base_dir = File.dirname(absolute)
311
368
  includes = Array(raw.delete("includes") || [])
@@ -314,6 +371,17 @@ module Rigor
314
371
  merge_includes(data, includes, base_dir, next_visited)
315
372
  end
316
373
 
374
+ # #433's sibling: a typo in the file the user is about to be told to fix is a configuration mistake
375
+ # like any other, and escaped as a `Psych::SyntaxError` backtrace naming a file inside Ruby's stdlib.
376
+ # Psych's own `#message` embeds the path in a parenthesised prefix that reads badly after `rigor: `,
377
+ # so the position is re-rendered in the `path:line:column` form the rest of Rigor's output uses.
378
+ def self.read_yaml(absolute)
379
+ YAML.safe_load_file(absolute, aliases: false) || {}
380
+ rescue Psych::SyntaxError => e
381
+ detail = [e.problem, e.context].compact.join(" ")
382
+ raise ConfigurationError, "#{absolute}:#{e.line}:#{e.column}: not valid YAML: #{detail}"
383
+ end
384
+
317
385
  def self.merge_includes(data, includes, base_dir, visited)
318
386
  return data if includes.empty?
319
387
 
@@ -321,7 +389,7 @@ module Rigor
321
389
  includes.each do |inc|
322
390
  inc_path = File.expand_path(inc.to_s, base_dir)
323
391
  unless File.exist?(inc_path)
324
- raise ArgumentError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
392
+ raise ConfigurationError, "include not found: #{inc.inspect} (referenced from #{base_dir})"
325
393
  end
326
394
 
327
395
  accumulated = deep_merge(accumulated, load_with_includes(inc_path, visited: visited))
@@ -394,7 +462,17 @@ module Rigor
394
462
  :merge_value, :merge_dependencies_hash
395
463
 
396
464
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
397
- def initialize(data = DEFAULTS)
465
+ #
466
+ # @param effects_key_present [Boolean] ADR-103 WD15 — whether the file this `data` came from carried an
467
+ # `effects:` key at all, independent of its value. Defaults to `data.key?("effects")`, which is the
468
+ # right answer for a caller that passes a raw (non-`DEFAULTS`-merged) hash directly — the common shape
469
+ # in specs — but is always `true` once `data` has been through `DEFAULTS.merge`, because `DEFAULTS`
470
+ # itself carries the key; {.load} therefore computes this from the pre-merge file and passes it
471
+ # explicitly. See {#coerce_effects}. **Positional, deliberately not a keyword**: a bare (unbraced)
472
+ # `"key" => value` hash literal — this class's usual call shape, all over the spec suite — is coerced
473
+ # into keyword arguments by Ruby whenever the method declares ANY keyword parameter, which would break
474
+ # every `Configuration.new("some_key" => value)` call site with an "unknown keyword" `ArgumentError`.
475
+ def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"))
398
476
  # Record before the per-key fetches below discard the evidence. Top level only, deliberately —
399
477
  # see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
400
478
  @unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
@@ -423,6 +501,18 @@ module Rigor
423
501
  # ADR-67 WD6a — resolve to a strict Boolean so a truthy non-`true` value (e.g. a stray String) does not
424
502
  # silently enable the gate; only the literal `true` activates the check-walk collector pre-pass.
425
503
  @parameter_inference = data.fetch("parameter_inference", DEFAULTS.fetch("parameter_inference")) == true
504
+ # Resolved before `@effects` — ADR-103 WD15's "effects-on-by-default" reads {#bleeding_edge_active?},
505
+ # which reads `@bleeding_edge_active_ids`.
506
+ @bleeding_edge = coerce_bleeding_edge(
507
+ data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
508
+ )
509
+ @bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
510
+ @bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
511
+ # ADR-103 WD13 — presence, not truthiness: `effects:` written with no body parses as nil and still
512
+ # means "on", so the key's presence in the loaded data is what {#effects_enabled?} reads.
513
+ @effects = coerce_effects(data, effects_key_present: effects_key_present)
514
+ coerce_effects_snapshot(@effects)
515
+ coerce_effects_policy(@effects)
426
516
  @cache_path = cache.fetch("path").to_s
427
517
  raw_max = cache.fetch("max_bytes")
428
518
  @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
@@ -436,11 +526,6 @@ module Rigor
436
526
  @severity_overrides = coerce_severity_overrides(
437
527
  data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
438
528
  )
439
- @bleeding_edge = coerce_bleeding_edge(
440
- data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
441
- )
442
- @bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
443
- @bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
444
529
  @dependencies = Dependencies.from_h(
445
530
  data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
446
531
  )
@@ -576,8 +661,217 @@ module Rigor
576
661
  copy.freeze
577
662
  end
578
663
 
664
+ # ADR-103 WD14 — the ad-hoc opt-in `rigor effects` uses when the project's configuration carries no
665
+ # `effects:` block: a sibling Configuration with an implicit empty block, every other field shared.
666
+ # A configuration that already enables effects is returned unchanged, so a project's own settings
667
+ # always win over the implicit ones.
668
+ #
669
+ # Same `dup` + re-`freeze` shape as {#with_bleeding_edge}: every other ivar is the receiver's deeply
670
+ # frozen value, safe to share read-only, and the result stays `Ractor.shareable?` for the worker path.
671
+ def with_effects_enabled
672
+ return self if effects_enabled?
673
+
674
+ copy = dup
675
+ copy.instance_variable_set(:@effects, {}.freeze)
676
+ # The implicit block defaults like a written one. `rigor effects` emits no diagnostic either way,
677
+ # but the two configurations must not differ in a field a later reader could branch on.
678
+ copy.instance_variable_set(:@effects_check, true)
679
+ copy.freeze
680
+ end
681
+
579
682
  private
580
683
 
684
+ # ADR-103 WD13 — the `effects:` block, or nil when the key is absent or explicitly `false`. A
685
+ # present-but-empty key (`effects:` alone, which YAML parses as nil) and `effects: {}` are the same
686
+ # thing: collection on, every sub-key defaulted. A non-Hash body is normalised to `{}` rather than
687
+ # rejected — the sub-keys have no reader yet, so there is nothing a malformed body could break, and
688
+ # tier 1 (the schema) is where its shape is answered.
689
+ #
690
+ # ADR-103 WD15 — a loaded file that carries no `effects:` key AT ALL (`effects_key_present == false`,
691
+ # so `value` can only be the `false` DEFAULTS fallback here, never a user's own explicit `false`) behaves
692
+ # as `effects: {}` when the "effects-on-by-default" bleeding-edge feature is adopted. An explicit
693
+ # `effects: false` — `effects_key_present == true` — always stays off regardless of the feature, and a
694
+ # written block always wins outright: the feature can only fill an *absence*.
695
+ def coerce_effects(data, effects_key_present:)
696
+ value = data.fetch("effects", false)
697
+ return {}.freeze if value == false && !effects_key_present && effects_forced_on_by_bleeding_edge?
698
+ return nil if value == false
699
+
700
+ (value.is_a?(Hash) ? value : {}).freeze
701
+ end
702
+
703
+ # ADR-103 WD15 — guarded by {BleedingEdge.known_id?} rather than calling {#bleeding_edge_active?}
704
+ # directly: that predicate RAISES for an id neither queued nor graduated, which is correct for a
705
+ # contributor's typo against the registry in this same checkout, but a spec that stubs
706
+ # `BleedingEdge::FEATURES` down to an unrelated single feature (a common pattern for isolating the
707
+ # selector plumbing — see `spec/rigor/configuration_spec.rb`) must not have Configuration construction
708
+ # explode merely because the id this internal default-application reads is not the one under test.
709
+ def effects_forced_on_by_bleeding_edge?
710
+ BleedingEdge.known_id?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID) &&
711
+ bleeding_edge_active?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID)
712
+ end
713
+
714
+ # ADR-103 WD7 / WD14 — the snapshot keys and the minimal `tolerated:` policy list (#381).
715
+ #
716
+ # Every key resolves to its default when `effects:` is absent, so a consumer reads one uniform surface
717
+ # and never has to ask whether the block was there. The values are read by `rigor effects update` /
718
+ # `check` / `diff` / `explain` and by nothing on `rigor check`'s path.
719
+ #
720
+ # Validation is **tier 2** (`ArgumentError`, the run stops), because each of these is a value the
721
+ # snapshot commands cannot proceed on: an unknown `gate:` would silently pick a semantics, an unknown
722
+ # preset name would silently match no file, and a malformed label would silently tolerate nothing.
723
+ # A label that is well-formed but unknown to the *registry* is deliberately NOT rejected here — that is
724
+ # `effect.unknown-label`'s job (#384), and it fails open.
725
+ def coerce_effects_snapshot(effects)
726
+ @effects_check = coerce_effects_check?(effects)
727
+ snapshot = effects&.fetch("snapshot", nil)
728
+ snapshot = {} unless snapshot.is_a?(Hash)
729
+ @effects_snapshot_path = (snapshot.fetch("path", nil) || DEFAULT_EFFECTS_SNAPSHOT_PATH).to_s.freeze
730
+ @effects_snapshot_reach = coerce_effects_reach(snapshot.fetch("reach", nil))
731
+ @effects_snapshot_gate = coerce_effects_gate(snapshot.fetch("gate", nil))
732
+ @effects_tolerated = coerce_effects_tolerated(effects&.fetch("tolerated", nil))
733
+ end
734
+
735
+ # `effects.check:` — true by default under a present block, and never true without one. Only an
736
+ # explicit `false` turns it off; any other value is normalised to true rather than rejected, because a
737
+ # switch whose only two readings are "check" and "do not check" has nothing a tier-2 error would add.
738
+ def coerce_effects_check?(effects)
739
+ return false if effects.nil?
740
+
741
+ effects.fetch("check", true) != false
742
+ end
743
+
744
+ VALID_EFFECTS_GATES = %i[symmetric additions].freeze
745
+ private_constant :VALID_EFFECTS_GATES
746
+
747
+ def coerce_effects_gate(value)
748
+ return :symmetric if value.nil?
749
+
750
+ gate = value.to_s.to_sym
751
+ unless VALID_EFFECTS_GATES.include?(gate)
752
+ raise ConfigurationError,
753
+ "effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
754
+ end
755
+
756
+ gate
757
+ end
758
+
759
+ # Entry-point globs and preset names, kept as written.
760
+ #
761
+ # **Shape only.** A preset is *named by a plugin* (#387; ADR-103 WD14) and plugins load from this very
762
+ # configuration, so at this point no preset is registered yet and asking `EntryPoints.known?` would
763
+ # reject `reach: [rails]` for every project that uses one. The existence check therefore lives where
764
+ # the snapshot expands `reach:` ({Effects::Snapshot.expand_reach}), which runs after plugin load and
765
+ # raises there — the same error, at the first moment it can be right.
766
+ def coerce_effects_reach(value)
767
+ entries = Array(value).map(&:to_s)
768
+ entries.each do |entry|
769
+ next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
770
+
771
+ raise ConfigurationError,
772
+ "effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
773
+ "name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
774
+ "anything carrying a path or glob character is treated as a file glob instead)"
775
+ end
776
+ entries.uniq.freeze
777
+ end
778
+
779
+ # Shape validation only: the label grammar of `docs/type-specification/effect-labels.md`.
780
+ def coerce_effects_tolerated(value)
781
+ labels = Array(value).map(&:to_s)
782
+ labels.each do |label|
783
+ raise ConfigurationError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
784
+ Effects::Label.valid?(label)
785
+ end
786
+ labels.uniq.sort.freeze
787
+ end
788
+
789
+ # ADR-103 WD5 / WD6 / #385 — the three policy keys: the project's own vocabulary (`labels:`), its
790
+ # attribution table for code Rigor does not analyse (`attribution:`), and its envelopes by convention
791
+ # (`envelopes:`).
792
+ #
793
+ # Validation is **tier 2** for shape and tier 2 only. A label whose SPELLING is malformed, an entry
794
+ # naming neither `match:` nor `namespace:` (or both), and an attribution key that is not a method key
795
+ # each stop the run, because none of them has a reading the loader could pick. A label that is
796
+ # well-formed but unknown to the *registry* is deliberately fine here: it fails open (⊤ for an
797
+ # envelope, an unregistered meaning for an attribution) and surfaces as `effect.unknown-label`.
798
+ def coerce_effects_policy(effects)
799
+ @effects_labels = coerce_effects_labels(effects&.fetch("labels", nil))
800
+ @effects_attribution = coerce_effects_attribution(effects&.fetch("attribution", nil))
801
+ @effects_envelopes = coerce_effects_envelopes(effects&.fetch("envelopes", nil))
802
+ end
803
+
804
+ NO_ATTRIBUTION = {}.freeze
805
+ private_constant :NO_ATTRIBUTION
806
+
807
+ def coerce_effects_labels(value)
808
+ labels = Array(value).map(&:to_s)
809
+ labels.each do |label|
810
+ raise ConfigurationError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
811
+ Effects::Label.valid?(label)
812
+ end
813
+ labels.uniq.sort.freeze
814
+ end
815
+
816
+ # `{ "Net::HTTP.get" => ["io.net.http"] }` — a method key exactly as the symbol tables spell one
817
+ # (`Owner#instance`, `Owner.singleton`), mapped to the labels a call to it contributes.
818
+ def coerce_effects_attribution(value)
819
+ return NO_ATTRIBUTION unless value.is_a?(Hash)
820
+
821
+ value.each_with_object({}) do |(key, labels), out|
822
+ name = key.to_s
823
+ unless Effects::MethodKey.valid?(name)
824
+ raise ConfigurationError,
825
+ "effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
826
+ end
827
+
828
+ out[name] = coerce_effect_label_list(Array(labels), "effects.attribution[#{name.inspect}]")
829
+ end.freeze
830
+ end
831
+
832
+ # `[{ match: | namespace:, effect: [...] }]`. Exactly one selector per entry: an entry naming both
833
+ # would need a precedence rule between two selectors of the same entry, and one naming neither selects
834
+ # every class in the project, which is never what an author meant to write.
835
+ def coerce_effects_envelopes(value)
836
+ Array(value).each_with_index.map { |entry, index| coerce_effects_envelope(entry, index) }.freeze
837
+ end
838
+
839
+ def coerce_effects_envelope(entry, index)
840
+ where = "effects.envelopes[#{index}]"
841
+ raise ConfigurationError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
842
+
843
+ match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
844
+ namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
845
+ if match.nil? == namespace.nil?
846
+ raise ConfigurationError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
847
+ "(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
848
+ end
849
+ unless entry.key?("effect")
850
+ raise ConfigurationError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
851
+ end
852
+
853
+ {
854
+ "match" => match, "namespace" => namespace,
855
+ "effect" => coerce_effect_label_list(Array(entry["effect"]), "#{where}.effect")
856
+ }.freeze
857
+ end
858
+
859
+ def coerce_effects_envelope_selector(value, where)
860
+ return nil if value.nil?
861
+
862
+ selector = value.to_s
863
+ raise ConfigurationError, "#{where} is empty" if selector.strip.empty?
864
+
865
+ selector.freeze
866
+ end
867
+
868
+ def coerce_effect_label_list(labels, where)
869
+ labels.map(&:to_s).each do |label|
870
+ raise ConfigurationError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
871
+ Effects::Label.valid?(label)
872
+ end.uniq.sort.freeze
873
+ end
874
+
581
875
  # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is accepted as either a literal path (slice 1
582
876
  # contract) OR a `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`). Glob meta characters (`*`,
583
877
  # `?`, `[`) trigger `Dir.glob` expansion; the resulting file list is folded into the `pre_eval:` set with
@@ -604,7 +898,7 @@ module Rigor
604
898
  when Hash
605
899
  entry.to_h { |k, v| [k.to_s, v] }.freeze
606
900
  else
607
- raise ArgumentError,
901
+ raise ConfigurationError,
608
902
  "plugin configuration entry must be a String or Hash, got #{entry.inspect}"
609
903
  end
610
904
  end
@@ -621,7 +915,7 @@ module Rigor
621
915
  def coerce_target_ruby(value)
622
916
  s = value.to_s
623
917
  unless s.match?(TARGET_RUBY_FORMAT)
624
- raise ArgumentError,
918
+ raise ConfigurationError,
625
919
  "target_ruby must be a version (e.g. \"3.4\", \"4.0\", \"3.4.0\") or \"latest\", got #{value.inspect}"
626
920
  end
627
921
 
@@ -642,11 +936,11 @@ module Rigor
642
936
  # disabling parallelism.
643
937
  def coerce_parallel_workers(value)
644
938
  integer = Integer(value)
645
- raise ArgumentError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
939
+ raise ConfigurationError, "parallel.workers must be >= 0, got #{value.inspect}" if integer.negative?
646
940
 
647
941
  integer
648
942
  rescue TypeError, ArgumentError => e
649
- raise ArgumentError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
943
+ raise ConfigurationError, "parallel.workers must be a non-negative Integer, got #{value.inspect} (#{e.message})"
650
944
  end
651
945
 
652
946
  # ADR-22 WD2 (b) — `baseline: <path>` activates the file; `baseline: false` is the explicit-disable form
@@ -673,7 +967,7 @@ module Rigor
673
967
  def coerce_network_policy(value)
674
968
  sym = value.to_sym
675
969
  unless VALID_NETWORK_POLICIES.include?(sym)
676
- raise ArgumentError,
970
+ raise ConfigurationError,
677
971
  "plugins_io.network must be one of #{VALID_NETWORK_POLICIES.inspect}, got #{value.inspect}"
678
972
  end
679
973
 
@@ -685,7 +979,7 @@ module Rigor
685
979
  def coerce_severity_profile(value)
686
980
  sym = value.to_sym
687
981
  unless SeverityProfile::VALID_PROFILES.include?(sym)
688
- raise ArgumentError,
982
+ raise ConfigurationError,
689
983
  "severity_profile must be one of " \
690
984
  "#{SeverityProfile::VALID_PROFILES.inspect}, got #{value.inspect}"
691
985
  end
@@ -698,7 +992,7 @@ module Rigor
698
992
  # {SeverityProfile::VALID_SEVERITIES} symbols (`:error` / `:warning` / `:info` / `:off`). Unknown
699
993
  # severities raise; unknown rule ids are silently kept (the override is inert until the rule lands).
700
994
  def coerce_severity_overrides(value)
701
- raise ArgumentError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
995
+ raise ConfigurationError, "severity_overrides must be a Hash, got #{value.inspect}" unless value.is_a?(Hash)
702
996
 
703
997
  value.to_h do |k, v|
704
998
  # YAML 1.1 parses bare `off`/`on`/`no`/`yes`/`true`/`false` as booleans, so a user who wrote `off` (a
@@ -706,7 +1000,7 @@ module Rigor
706
1000
  # `to_sym` blows up with a backtrace.
707
1001
  unless v.is_a?(String) || v.is_a?(Symbol)
708
1002
  hint = v == false ? %( — did you mean the string "off"?) : ""
709
- raise ArgumentError,
1003
+ raise ConfigurationError,
710
1004
  "severity_overrides[#{k.inspect}] is #{v.inspect}, a YAML boolean#{hint} " \
711
1005
  "Bare off/on/no/yes/true/false are parsed as booleans; quote the severity " \
712
1006
  "(e.g. \"off\")."
@@ -714,7 +1008,7 @@ module Rigor
714
1008
 
715
1009
  sym = v.to_sym
716
1010
  unless SeverityProfile::VALID_SEVERITIES.include?(sym)
717
- raise ArgumentError,
1011
+ raise ConfigurationError,
718
1012
  "severity_overrides[#{k.inspect}] must be one of " \
719
1013
  "#{SeverityProfile::VALID_SEVERITIES.inspect}, got #{v.inspect}"
720
1014
  end
@@ -734,7 +1028,7 @@ module Rigor
734
1028
  when Array then { "mode" => "list", "ids" => freeze_ids(value) }
735
1029
  when Hash then coerce_bleeding_edge_hash(value)
736
1030
  else
737
- raise ArgumentError,
1031
+ raise ConfigurationError,
738
1032
  "bleeding_edge must be true, false, a list of feature ids, " \
739
1033
  "or { all: true, except: [...] }, got #{value.inspect}"
740
1034
  end.freeze
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # A mistake in the project's configuration — a value `Configuration` cannot proceed on (the tier-2
5
+ # failures `docs/internal-spec/config.md` enumerates), or a `.rigor.yml` key the loader accepted whose
6
+ # meaning only resolves later, like an `effects.snapshot.reach:` preset name.
7
+ #
8
+ # It exists so the CLI can tell **the user got the file wrong** apart from **Rigor got itself wrong**.
9
+ # Both used to reach the terminal as an uncaught `ArgumentError` with a thirty-frame backtrace naming a
10
+ # file inside `lib/rigor/`, which reads as a crash: the reader's first move is to file a bug, not to
11
+ # fix the key the message already named (#433). {CLI#run} rescues this class and renders it as a single
12
+ # `rigor:` line.
13
+ #
14
+ # It stays an `ArgumentError` subclass deliberately. `Configuration` raised `ArgumentError` from every
15
+ # coercion since the beginning, that is the documented tier-2 contract, and callers outside the CLI —
16
+ # the language server, embedders, the suite — rescue it by that name. Narrowing the class is a
17
+ # presentation change, not a contract change.
18
+ class ConfigurationError < ArgumentError
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "label_set"
4
+ require_relative "method_key"
5
+
6
+ module Rigor
7
+ module Effects
8
+ # The project's own `effects.attribution:` table — what a call into code Rigor never analysed does
9
+ # (ADR-103 WD5 (6) / WD6; design note § 6.6).
10
+ #
11
+ # A gem method has no body here, so someone must colour it. The catalogue ({Catalog}) answers for
12
+ # Ruby's own surface, a plugin answers for a framework it models, and this table is the project's
13
+ # answer for everything left: `attribution: {"Net::HTTP.get": [io.net.http]}`.
14
+ #
15
+ # **It never discharges the taint.** A configured attribution is an unchecked claim about code the
16
+ # analyzer did not read, so WD6 puts it in the *declared* lane and keeps the site's
17
+ # `plugin-attribution` cause: the summary reads "declared this, and possibly more" rather than
18
+ # "proven this". That is the whole difference between this table and a catalogue row, and it is why an
19
+ # envelope can never fire because of one — diagnostics read the proven lane only.
20
+ #
21
+ # The lookup is deliberately the same shape the catalogue's is: the owner the syntax names when it
22
+ # names one (`Net::HTTP.get`), and the class the typer projected the receiver to otherwise. So a table
23
+ # keyed `Logger#info` colours `logger.info(…)` on a receiver typed `Logger`, by name, without needing
24
+ # the dispatcher to have resolved anything.
25
+ class Attribution
26
+ EMPTY_ROWS = {}.freeze
27
+ private_constant :EMPTY_ROWS
28
+
29
+ def self.empty
30
+ @empty ||= new({})
31
+ end
32
+
33
+ # @param table [Hash{String => Array<String>}] `Configuration#effects_attribution` — method key to
34
+ # label list, both already shape-validated at load (tier 2).
35
+ def self.build(table)
36
+ return empty if table.nil? || table.empty?
37
+
38
+ new(table)
39
+ end
40
+
41
+ def initialize(table)
42
+ @rows = build_rows(table)
43
+ freeze
44
+ end
45
+
46
+ def empty?
47
+ @rows.empty?
48
+ end
49
+
50
+ # The labels attributed to a method key (`Net::HTTP.get`), or nil when the table says nothing about
51
+ # it. The key is spelled by the caller, which already builds the same string for the catalogue.
52
+ #
53
+ # @return [LabelSet, nil]
54
+ def [](key)
55
+ @rows[key]
56
+ end
57
+
58
+ private
59
+
60
+ # A malformed key cannot arrive from `Configuration` (tier 2 rejects it), but this class is also
61
+ # constructed straight from a Hash in specs and by a future plugin channel (#387), so an
62
+ # unparseable key is dropped rather than raised on: attribution is an enrichment, and a bad row
63
+ # must not take a run down.
64
+ def build_rows(table)
65
+ return EMPTY_ROWS if table.empty?
66
+
67
+ table.each_with_object({}) do |(key, labels), out|
68
+ next if MethodKey.split(key.to_s).nil?
69
+
70
+ set = LabelSet.new(Array(labels).map(&:to_s))
71
+ out[key.to_s] = set unless set.empty?
72
+ end.freeze
73
+ end
74
+ end
75
+ end
76
+ end