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
@@ -6,6 +6,9 @@ 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 "effects/entry_points"
10
+ require_relative "effects/label"
11
+ require_relative "effects/method_key"
9
12
 
10
13
  module Rigor
11
14
  class Configuration # rubocop:disable Metrics/ClassLength
@@ -22,6 +25,15 @@ module Rigor
22
25
  # fixture paths still work; the discovery list is the canonical source.
23
26
  DEFAULT_PATH = DISCOVERY_ORDER.first
24
27
 
28
+ # ADR-103 WD7 — where `rigor effects update` writes when `effects.snapshot.path:` is unset. Public
29
+ # because the schema pins its own `default` against it.
30
+ DEFAULT_EFFECTS_SNAPSHOT_PATH = ".rigor-effects.yml"
31
+
32
+ # ADR-103 WD15 — the {BleedingEdge::FEATURES} id that, when adopted, makes a loaded file with no
33
+ # `effects:` key behave as `effects: {}`. See {#coerce_effects}.
34
+ EFFECTS_ON_BY_DEFAULT_FEATURE_ID = "effects-on-by-default"
35
+ private_constant :EFFECTS_ON_BY_DEFAULT_FEATURE_ID
36
+
25
37
  # Built-in exclusion patterns appended to `exclude:` so vendored dependencies, Bundler artefacts, and
26
38
  # JavaScript node_modules are never analysed by accident when a directory glob expands. Users cannot
27
39
  # disable these defaults; the trade-off is that analysing any of these paths is essentially never what
@@ -65,6 +77,24 @@ module Rigor
65
77
  # lifted): the incremental session recomputes the table each run and diffs it against its snapshot,
66
78
  # re-checking any callee whose seeds moved.
67
79
  "parameter_inference" => false,
80
+ # ADR-103 WD13 — the effect-labels opt-in, and the ONLY thing that turns effect collection on
81
+ # besides running `rigor effects`. **Presence is the switch**: `effects: {}` — or the bare key, which
82
+ # YAML parses as nil — enables collection with every sub-key at its default, because an annotation in
83
+ # a project's RBS must never create a project-wide cost cliff (that case earns a `:info` residual
84
+ # instead, #384).
85
+ #
86
+ # The default is `false` rather than `nil` precisely because presence is the switch and
87
+ # `Configuration.load` merges these DEFAULTS UNDER the loaded file: a `nil` default would be
88
+ # indistinguishable from a user's bare `effects:` key and would turn collection on for every project
89
+ # in existence. `false` is also the explicit-disable form a `.rigor.yml` uses to override an upstream
90
+ # `.rigor.dist.yml`'s `effects:` block — the same shape `baseline:` uses.
91
+ #
92
+ # The sub-keys are declared in `schemas/rigor-config.schema.json`. `check` (#383,
93
+ # {#effects_check?}), `snapshot.{path,reach,gate}`, `tolerated` (#381) and the policy trio
94
+ # `labels` / `attribution` / `envelopes` (#385, {#coerce_effects_policy}) are read; `views` lands
95
+ # with the slice that implements it (#390). Reserving a sub-key's shape before a reader exists is
96
+ # the same discipline the schema applies to a sibling implementation's namespace (ADR-99).
97
+ "effects" => false,
68
98
  "cache" => {
69
99
  "path" => ".rigor/cache",
70
100
  # LRU eviction cap in bytes (ADR-54 WD3). The least-recently-used entries are removed at the end of a
@@ -211,7 +241,27 @@ module Rigor
211
241
  :dependencies, :parallel_workers,
212
242
  :bundler_bundle_path, :bundler_auto_detect, :bundler_lockfile,
213
243
  :rbs_collection_lockfile, :rbs_collection_auto_detect,
214
- :pre_eval, :baseline_path
244
+ :pre_eval, :baseline_path, :effects,
245
+ :effects_snapshot_path, :effects_snapshot_reach, :effects_snapshot_gate, :effects_tolerated,
246
+ :effects_labels, :effects_attribution, :effects_envelopes
247
+
248
+ # ADR-103 WD13 — whether effect collection runs. True exactly when the loaded configuration carried an
249
+ # `effects:` block, whatever its body; `rigor effects` enables it for its own run by loading an
250
+ # implicit `effects: {}` instead of by consulting anything else. Nothing else — no annotation, no
251
+ # plugin, no severity profile — can turn collection on.
252
+ def effects_enabled?
253
+ !@effects.nil?
254
+ end
255
+
256
+ # ADR-103 WD14 / #383 — whether author-declared effect envelopes (`%a{pure}`,
257
+ # `%a{rigor:v1:effect …}`) are checked against the collected summaries, surfacing
258
+ # `effect.envelope-exceeded`. Defaults to **true when the `effects:` block is present**: the block is
259
+ # the opt-in, and a project that asked for effects asked for its own declarations to hold. An explicit
260
+ # `check: false` disables the diagnostic and leaves collection — and therefore `rigor effects` and the
261
+ # snapshot — untouched. Always false without the block, so an annotation alone changes nothing.
262
+ def effects_check?
263
+ @effects_check
264
+ end
215
265
 
216
266
  # Loads a configuration file.
217
267
  #
@@ -226,13 +276,19 @@ module Rigor
226
276
  # [PHPStan](https://phpstan.org/config-reference#paths).
227
277
  def self.load(path = nil)
228
278
  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))
279
+ if resolved.nil? || !File.exist?(resolved)
280
+ data = DEFAULTS
281
+ effects_key_present = false
282
+ else
283
+ # ADR-103 WD15 — captured from the RAW, pre-`DEFAULTS.merge` file (+ its `includes:` chain) because
284
+ # `DEFAULTS` itself carries an `"effects" => false` entry: once merged, "the file never wrote
285
+ # `effects:`" and "the file wrote `effects: false`" collapse to the same value and become
286
+ # indistinguishable to {#initialize}. This is the only place that distinction still exists.
287
+ raw = load_with_includes(resolved)
288
+ effects_key_present = raw.key?("effects")
289
+ data = DEFAULTS.merge(raw)
290
+ end
291
+ new(autowire_default_plugins(data), effects_key_present)
236
292
  end
237
293
 
238
294
  # ADR-93 WD2 — the one bundled plugin default-wired without a `plugins:` entry. `rigor-rbs-inline` is the
@@ -394,7 +450,17 @@ module Rigor
394
450
  :merge_value, :merge_dependencies_hash
395
451
 
396
452
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
397
- def initialize(data = DEFAULTS)
453
+ #
454
+ # @param effects_key_present [Boolean] ADR-103 WD15 — whether the file this `data` came from carried an
455
+ # `effects:` key at all, independent of its value. Defaults to `data.key?("effects")`, which is the
456
+ # right answer for a caller that passes a raw (non-`DEFAULTS`-merged) hash directly — the common shape
457
+ # in specs — but is always `true` once `data` has been through `DEFAULTS.merge`, because `DEFAULTS`
458
+ # itself carries the key; {.load} therefore computes this from the pre-merge file and passes it
459
+ # explicitly. See {#coerce_effects}. **Positional, deliberately not a keyword**: a bare (unbraced)
460
+ # `"key" => value` hash literal — this class's usual call shape, all over the spec suite — is coerced
461
+ # into keyword arguments by Ruby whenever the method declares ANY keyword parameter, which would break
462
+ # every `Configuration.new("some_key" => value)` call site with an "unknown keyword" `ArgumentError`.
463
+ def initialize(data = DEFAULTS, effects_key_present = data.key?("effects"))
398
464
  # Record before the per-key fetches below discard the evidence. Top level only, deliberately —
399
465
  # see {ConfigAudit.unknown_key_warnings} for why a nested check cannot key on DEFAULTS.
400
466
  @unknown_keys = (data.keys.map(&:to_s) - KNOWN_KEYS).sort.freeze
@@ -423,6 +489,18 @@ module Rigor
423
489
  # ADR-67 WD6a — resolve to a strict Boolean so a truthy non-`true` value (e.g. a stray String) does not
424
490
  # silently enable the gate; only the literal `true` activates the check-walk collector pre-pass.
425
491
  @parameter_inference = data.fetch("parameter_inference", DEFAULTS.fetch("parameter_inference")) == true
492
+ # Resolved before `@effects` — ADR-103 WD15's "effects-on-by-default" reads {#bleeding_edge_active?},
493
+ # which reads `@bleeding_edge_active_ids`.
494
+ @bleeding_edge = coerce_bleeding_edge(
495
+ data.fetch("bleeding_edge", DEFAULTS.fetch("bleeding_edge"))
496
+ )
497
+ @bleeding_edge_severity_overrides = BleedingEdge.severity_overrides_for(@bleeding_edge)
498
+ @bleeding_edge_active_ids = BleedingEdge.active_ids_for(@bleeding_edge)
499
+ # ADR-103 WD13 — presence, not truthiness: `effects:` written with no body parses as nil and still
500
+ # means "on", so the key's presence in the loaded data is what {#effects_enabled?} reads.
501
+ @effects = coerce_effects(data, effects_key_present: effects_key_present)
502
+ coerce_effects_snapshot(@effects)
503
+ coerce_effects_policy(@effects)
426
504
  @cache_path = cache.fetch("path").to_s
427
505
  raw_max = cache.fetch("max_bytes")
428
506
  @cache_max_bytes = raw_max.nil? ? nil : Integer(raw_max)
@@ -436,11 +514,6 @@ module Rigor
436
514
  @severity_overrides = coerce_severity_overrides(
437
515
  data.fetch("severity_overrides", DEFAULTS.fetch("severity_overrides"))
438
516
  )
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
517
  @dependencies = Dependencies.from_h(
445
518
  data.fetch("dependencies", DEFAULTS.fetch("dependencies"))
446
519
  )
@@ -576,8 +649,217 @@ module Rigor
576
649
  copy.freeze
577
650
  end
578
651
 
652
+ # ADR-103 WD14 — the ad-hoc opt-in `rigor effects` uses when the project's configuration carries no
653
+ # `effects:` block: a sibling Configuration with an implicit empty block, every other field shared.
654
+ # A configuration that already enables effects is returned unchanged, so a project's own settings
655
+ # always win over the implicit ones.
656
+ #
657
+ # Same `dup` + re-`freeze` shape as {#with_bleeding_edge}: every other ivar is the receiver's deeply
658
+ # frozen value, safe to share read-only, and the result stays `Ractor.shareable?` for the worker path.
659
+ def with_effects_enabled
660
+ return self if effects_enabled?
661
+
662
+ copy = dup
663
+ copy.instance_variable_set(:@effects, {}.freeze)
664
+ # The implicit block defaults like a written one. `rigor effects` emits no diagnostic either way,
665
+ # but the two configurations must not differ in a field a later reader could branch on.
666
+ copy.instance_variable_set(:@effects_check, true)
667
+ copy.freeze
668
+ end
669
+
579
670
  private
580
671
 
672
+ # ADR-103 WD13 — the `effects:` block, or nil when the key is absent or explicitly `false`. A
673
+ # present-but-empty key (`effects:` alone, which YAML parses as nil) and `effects: {}` are the same
674
+ # thing: collection on, every sub-key defaulted. A non-Hash body is normalised to `{}` rather than
675
+ # rejected — the sub-keys have no reader yet, so there is nothing a malformed body could break, and
676
+ # tier 1 (the schema) is where its shape is answered.
677
+ #
678
+ # ADR-103 WD15 — a loaded file that carries no `effects:` key AT ALL (`effects_key_present == false`,
679
+ # so `value` can only be the `false` DEFAULTS fallback here, never a user's own explicit `false`) behaves
680
+ # as `effects: {}` when the "effects-on-by-default" bleeding-edge feature is adopted. An explicit
681
+ # `effects: false` — `effects_key_present == true` — always stays off regardless of the feature, and a
682
+ # written block always wins outright: the feature can only fill an *absence*.
683
+ def coerce_effects(data, effects_key_present:)
684
+ value = data.fetch("effects", false)
685
+ return {}.freeze if value == false && !effects_key_present && effects_forced_on_by_bleeding_edge?
686
+ return nil if value == false
687
+
688
+ (value.is_a?(Hash) ? value : {}).freeze
689
+ end
690
+
691
+ # ADR-103 WD15 — guarded by {BleedingEdge.known_id?} rather than calling {#bleeding_edge_active?}
692
+ # directly: that predicate RAISES for an id neither queued nor graduated, which is correct for a
693
+ # contributor's typo against the registry in this same checkout, but a spec that stubs
694
+ # `BleedingEdge::FEATURES` down to an unrelated single feature (a common pattern for isolating the
695
+ # selector plumbing — see `spec/rigor/configuration_spec.rb`) must not have Configuration construction
696
+ # explode merely because the id this internal default-application reads is not the one under test.
697
+ def effects_forced_on_by_bleeding_edge?
698
+ BleedingEdge.known_id?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID) &&
699
+ bleeding_edge_active?(EFFECTS_ON_BY_DEFAULT_FEATURE_ID)
700
+ end
701
+
702
+ # ADR-103 WD7 / WD14 — the snapshot keys and the minimal `tolerated:` policy list (#381).
703
+ #
704
+ # Every key resolves to its default when `effects:` is absent, so a consumer reads one uniform surface
705
+ # and never has to ask whether the block was there. The values are read by `rigor effects update` /
706
+ # `check` / `diff` / `explain` and by nothing on `rigor check`'s path.
707
+ #
708
+ # Validation is **tier 2** (`ArgumentError`, the run stops), because each of these is a value the
709
+ # snapshot commands cannot proceed on: an unknown `gate:` would silently pick a semantics, an unknown
710
+ # preset name would silently match no file, and a malformed label would silently tolerate nothing.
711
+ # A label that is well-formed but unknown to the *registry* is deliberately NOT rejected here — that is
712
+ # `effect.unknown-label`'s job (#384), and it fails open.
713
+ def coerce_effects_snapshot(effects)
714
+ @effects_check = coerce_effects_check?(effects)
715
+ snapshot = effects&.fetch("snapshot", nil)
716
+ snapshot = {} unless snapshot.is_a?(Hash)
717
+ @effects_snapshot_path = (snapshot.fetch("path", nil) || DEFAULT_EFFECTS_SNAPSHOT_PATH).to_s.freeze
718
+ @effects_snapshot_reach = coerce_effects_reach(snapshot.fetch("reach", nil))
719
+ @effects_snapshot_gate = coerce_effects_gate(snapshot.fetch("gate", nil))
720
+ @effects_tolerated = coerce_effects_tolerated(effects&.fetch("tolerated", nil))
721
+ end
722
+
723
+ # `effects.check:` — true by default under a present block, and never true without one. Only an
724
+ # explicit `false` turns it off; any other value is normalised to true rather than rejected, because a
725
+ # switch whose only two readings are "check" and "do not check" has nothing a tier-2 error would add.
726
+ def coerce_effects_check?(effects)
727
+ return false if effects.nil?
728
+
729
+ effects.fetch("check", true) != false
730
+ end
731
+
732
+ VALID_EFFECTS_GATES = %i[symmetric additions].freeze
733
+ private_constant :VALID_EFFECTS_GATES
734
+
735
+ def coerce_effects_gate(value)
736
+ return :symmetric if value.nil?
737
+
738
+ gate = value.to_s.to_sym
739
+ unless VALID_EFFECTS_GATES.include?(gate)
740
+ raise ArgumentError,
741
+ "effects.snapshot.gate must be one of #{VALID_EFFECTS_GATES.inspect}, got #{value.inspect}"
742
+ end
743
+
744
+ gate
745
+ end
746
+
747
+ # Entry-point globs and preset names, kept as written.
748
+ #
749
+ # **Shape only.** A preset is *named by a plugin* (#387; ADR-103 WD14) and plugins load from this very
750
+ # configuration, so at this point no preset is registered yet and asking `EntryPoints.known?` would
751
+ # reject `reach: [rails]` for every project that uses one. The existence check therefore lives where
752
+ # the snapshot expands `reach:` ({Effects::Snapshot.expand_reach}), which runs after plugin load and
753
+ # raises there — the same error, at the first moment it can be right.
754
+ def coerce_effects_reach(value)
755
+ entries = Array(value).map(&:to_s)
756
+ entries.each do |entry|
757
+ next if Effects::EntryPoints.glob?(entry) || Effects::EntryPoints.name?(entry)
758
+
759
+ raise ArgumentError,
760
+ "effects.snapshot.reach entry is neither a file glob nor a well-formed entry-point preset " \
761
+ "name: #{entry.inspect} (a preset name is #{Effects::EntryPoints::NAME_PATTERN.inspect}; " \
762
+ "anything carrying a path or glob character is treated as a file glob instead)"
763
+ end
764
+ entries.uniq.freeze
765
+ end
766
+
767
+ # Shape validation only: the label grammar of `docs/type-specification/effect-labels.md`.
768
+ def coerce_effects_tolerated(value)
769
+ labels = Array(value).map(&:to_s)
770
+ labels.each do |label|
771
+ raise ArgumentError, "effects.tolerated is not a well-formed effect label: #{label.inspect}" unless
772
+ Effects::Label.valid?(label)
773
+ end
774
+ labels.uniq.sort.freeze
775
+ end
776
+
777
+ # ADR-103 WD5 / WD6 / #385 — the three policy keys: the project's own vocabulary (`labels:`), its
778
+ # attribution table for code Rigor does not analyse (`attribution:`), and its envelopes by convention
779
+ # (`envelopes:`).
780
+ #
781
+ # Validation is **tier 2** for shape and tier 2 only. A label whose SPELLING is malformed, an entry
782
+ # naming neither `match:` nor `namespace:` (or both), and an attribution key that is not a method key
783
+ # each stop the run, because none of them has a reading the loader could pick. A label that is
784
+ # well-formed but unknown to the *registry* is deliberately fine here: it fails open (⊤ for an
785
+ # envelope, an unregistered meaning for an attribution) and surfaces as `effect.unknown-label`.
786
+ def coerce_effects_policy(effects)
787
+ @effects_labels = coerce_effects_labels(effects&.fetch("labels", nil))
788
+ @effects_attribution = coerce_effects_attribution(effects&.fetch("attribution", nil))
789
+ @effects_envelopes = coerce_effects_envelopes(effects&.fetch("envelopes", nil))
790
+ end
791
+
792
+ NO_ATTRIBUTION = {}.freeze
793
+ private_constant :NO_ATTRIBUTION
794
+
795
+ def coerce_effects_labels(value)
796
+ labels = Array(value).map(&:to_s)
797
+ labels.each do |label|
798
+ raise ArgumentError, "effects.labels is not a well-formed effect label: #{label.inspect}" unless
799
+ Effects::Label.valid?(label)
800
+ end
801
+ labels.uniq.sort.freeze
802
+ end
803
+
804
+ # `{ "Net::HTTP.get" => ["io.net.http"] }` — a method key exactly as the symbol tables spell one
805
+ # (`Owner#instance`, `Owner.singleton`), mapped to the labels a call to it contributes.
806
+ def coerce_effects_attribution(value)
807
+ return NO_ATTRIBUTION unless value.is_a?(Hash)
808
+
809
+ value.each_with_object({}) do |(key, labels), out|
810
+ name = key.to_s
811
+ unless Effects::MethodKey.valid?(name)
812
+ raise ArgumentError,
813
+ "effects.attribution key is not a method key (`Owner#method` / `Owner.method`): #{name.inspect}"
814
+ end
815
+
816
+ out[name] = coerce_effect_label_list(Array(labels), "effects.attribution[#{name.inspect}]")
817
+ end.freeze
818
+ end
819
+
820
+ # `[{ match: | namespace:, effect: [...] }]`. Exactly one selector per entry: an entry naming both
821
+ # would need a precedence rule between two selectors of the same entry, and one naming neither selects
822
+ # every class in the project, which is never what an author meant to write.
823
+ def coerce_effects_envelopes(value)
824
+ Array(value).each_with_index.map { |entry, index| coerce_effects_envelope(entry, index) }.freeze
825
+ end
826
+
827
+ def coerce_effects_envelope(entry, index)
828
+ where = "effects.envelopes[#{index}]"
829
+ raise ArgumentError, "#{where} is not a mapping: #{entry.inspect}" unless entry.is_a?(Hash)
830
+
831
+ match = coerce_effects_envelope_selector(entry["match"], "#{where}.match")
832
+ namespace = coerce_effects_envelope_selector(entry["namespace"], "#{where}.namespace")
833
+ if match.nil? == namespace.nil?
834
+ raise ArgumentError, "#{where} must name exactly one of `match:` (a path glob) or `namespace:` " \
835
+ "(a constant glob), got #{match.nil? ? 'neither' : 'both'}"
836
+ end
837
+ unless entry.key?("effect")
838
+ raise ArgumentError, "#{where} has no `effect:` bound (write `effect: []` for the empty envelope)"
839
+ end
840
+
841
+ {
842
+ "match" => match, "namespace" => namespace,
843
+ "effect" => coerce_effect_label_list(Array(entry["effect"]), "#{where}.effect")
844
+ }.freeze
845
+ end
846
+
847
+ def coerce_effects_envelope_selector(value, where)
848
+ return nil if value.nil?
849
+
850
+ selector = value.to_s
851
+ raise ArgumentError, "#{where} is empty" if selector.strip.empty?
852
+
853
+ selector.freeze
854
+ end
855
+
856
+ def coerce_effect_label_list(labels, where)
857
+ labels.map(&:to_s).each do |label|
858
+ raise ArgumentError, "#{where} is not a well-formed effect label: #{label.inspect}" unless
859
+ Effects::Label.valid?(label)
860
+ end.uniq.sort.freeze
861
+ end
862
+
581
863
  # ADR-17 slice 4 — `pre_eval:` glob expansion. Each entry is accepted as either a literal path (slice 1
582
864
  # contract) OR a `File.fnmatch?`-shaped glob pattern (`lib/core_ext/**/*.rb`). Glob meta characters (`*`,
583
865
  # `?`, `[`) trigger `Dir.glob` expansion; the resulting file list is folded into the `pre_eval:` set with
@@ -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