rigortype 0.3.3 → 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 (158) 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 +6 -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/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -728,6 +728,164 @@ module Rigor
728
728
  since: "0.3.0"
729
729
  ),
730
730
 
731
+ CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED => Entry.new(
732
+ id: CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED,
733
+ summary: "A method performs an effect its declared envelope does not admit.",
734
+ fires_when: [
735
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
736
+ "defaults to on when the block is present).",
737
+ "The method's RBS carries an envelope — `%a{pure}` (the empty bound) or " \
738
+ "`%a{rigor:v1:effect <labels>}` — written on the method, or on its class / module and " \
739
+ "distributed to it.",
740
+ "The method's PROVEN effect labels — its own body plus the transitive closure over the " \
741
+ "project methods it calls — include a label no member of the bound subsumes."
742
+ ],
743
+ does_not_fire_when: [
744
+ "No `effects:` block is configured, or `effects.check: false` is set — an annotation " \
745
+ "alone never turns the check on.",
746
+ "The exceeding label is `mutate.local`: mutating what the frame itself allocated and " \
747
+ "never let escape is tolerated by every envelope, `%a{pure}` included.",
748
+ "The label is only suspected rather than proven — an unresolved or dynamic call taints " \
749
+ "the summary's exhaustiveness bit and contributes nothing to the proven lane, so " \
750
+ "\"possibly more\" never produces a finding.",
751
+ "The envelope names a label the effect registry does not recognise (a typo, a retired " \
752
+ "spelling): the whole tag then reads as unbounded, which suppresses findings rather " \
753
+ "than inventing them.",
754
+ "The envelope was written on a supertype rather than on this class — the inherited-bound " \
755
+ "(Liskov) reading is `effect.liskov-widened`.",
756
+ "The envelope was written outside the project's own `signature_paths:` RBS (core, a " \
757
+ "gem's shipped RBS): only project-authored envelopes are checked. Such an envelope is " \
758
+ "still imported as a `≤` bound at calls INTO it, which produces no finding."
759
+ ],
760
+ suppression: "`# rigor:disable effect.envelope-exceeded` on the Ruby `def` line (the " \
761
+ "diagnostic is positioned there, not on the `.rbs` line), or " \
762
+ "`disable: [\"effect.envelope-exceeded\"]` in `.rigor.yml`. Widening or " \
763
+ "removing the envelope is the real fix.",
764
+ severity_authored: :warning,
765
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
766
+ # FP-safe by two accepted constructions at once: opt-in by author directive (the envelope IS
767
+ # the directive, so a firing is never unsolicited — the `conforms-to` construction) and
768
+ # as-strict-as-proven (the proven lane only; taint never fires). What it costs to be wrong is
769
+ # bounded by the author having asked the question.
770
+ evidence_tier: :high,
771
+ since: "0.3.4"
772
+ ),
773
+
774
+ CheckRules::RULE_EFFECT_LISKOV_WIDENED => Entry.new(
775
+ id: CheckRules::RULE_EFFECT_LISKOV_WIDENED,
776
+ summary: "An override performs or declares an effect the envelope it inherits does not admit.",
777
+ fires_when: [
778
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
779
+ "defaults to on when the block is present).",
780
+ "A method the project defines redefines a method of a SUPERCLASS, and that ancestor's " \
781
+ "definition carries an envelope — written on it, distributed from its class, or put " \
782
+ "there by an `effects.envelopes:` convention. The nearest enveloped ancestor wins.",
783
+ "The override declares NO envelope of its own and its PROVEN effect labels — its body " \
784
+ "plus the transitive closure over the project methods it calls — include a label the " \
785
+ "inherited bound does not admit.",
786
+ "Or the override declares its OWN envelope and that bound is wider than the inherited " \
787
+ "one. This half is proven-independent: two authored bounds, compared by subsumption."
788
+ ],
789
+ does_not_fire_when: [
790
+ "No `effects:` block is configured, or `effects.check: false` is set.",
791
+ "Nobody wrote an envelope on the overridden method. The check is both-sides-authored: " \
792
+ "an override alone can never produce it.",
793
+ "The override is purer than the bound it inherits — that is the whole point of an upper " \
794
+ "bound, and a narrower envelope on an override is correct by construction.",
795
+ "The relation is a module include rather than a subclass. An includer's own `def` sits " \
796
+ "AHEAD of the module's in Ruby's ancestry rather than under it, so the substitutability " \
797
+ "argument that licenses this check does not apply.",
798
+ "The exceeding label is `mutate.local`, or the label is only suspected rather than " \
799
+ "proven: an unresolved or dynamic call taints exhaustiveness and contributes nothing to " \
800
+ "the proven lane.",
801
+ "The ancestor's envelope names a label the registry does not recognise: the whole tag " \
802
+ "reads as unbounded, which suppresses findings rather than inventing them."
803
+ ],
804
+ suppression: "`# rigor:disable effect.liskov-widened` on the override's Ruby `def` line, or " \
805
+ "`disable: [\"effect.liskov-widened\"]` in `.rigor.yml`. Widening the " \
806
+ "ancestor's envelope — or moving the effect out of the override — is the real " \
807
+ "fix.",
808
+ severity_authored: :warning,
809
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
810
+ # Both-sides-authored in the ADR-35 sense — an envelope on the ancestor is the directive, and
811
+ # the override is a `def` the project wrote — and as strict as proven on the half that reads a
812
+ # body at all. The other half compares two authored bounds and reads nothing inferred.
813
+ evidence_tier: :high,
814
+ since: "0.3.4"
815
+ ),
816
+
817
+ CheckRules::RULE_EFFECT_UNKNOWN_LABEL => Entry.new(
818
+ id: CheckRules::RULE_EFFECT_UNKNOWN_LABEL,
819
+ summary: "An effect declaration names a label the registry does not recognise, so it " \
820
+ "bounds nothing.",
821
+ fires_when: [
822
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on — the " \
823
+ "same gate as `effect.envelope-exceeded`, because opting into envelope enforcement is " \
824
+ "what turns on the diagnostic that says an envelope stopped enforcing.",
825
+ "A `%a{rigor:v1:effect <labels>}` envelope — in `.rbs`, or written as an rbs-inline " \
826
+ "`# @rbs %a{…}` comment — names a token the effect registry does not know after plugin " \
827
+ "load, which makes the WHOLE tag read as unbounded (⊤), or an `effects.tolerated:` " \
828
+ "entry names one, which then tolerates nothing.",
829
+ "Label intent is evident from one of four signals: the spelling is within two edits of " \
830
+ "a known label, another member of the same list is known, the token carries two or " \
831
+ "more dot-separated segments, or the registry's retired table names it."
832
+ ],
833
+ does_not_fire_when: [
834
+ "No `effects:` block is configured, or `effects.check: false` is set.",
835
+ "The unrecognised token is a lone far-off word (`database`): a vocabulary is open by " \
836
+ "design, so a bare word nothing resembles is as likely to be a label this project has " \
837
+ "not registered as it is a typo. The tag still reads ⊤ — silence here is about the " \
838
+ "diagnostic, never about the reading.",
839
+ "The tag is malformed rather than unrecognised (`io/db`, an empty list): a grammar " \
840
+ "violation is a different condition, reported through the `RBS::Extended` conflict " \
841
+ "channel.",
842
+ "The label is registered — by the shared registry, by `effects.labels:`, or by a " \
843
+ "plugin that owns its root."
844
+ ],
845
+ suppression: "`disable: [\"effect.unknown-label\"]` in `.rigor.yml`, or a baseline entry. " \
846
+ "A `# rigor:disable` comment works only where the diagnostic lands in a " \
847
+ "`.rb` file (an rbs-inline annotation); Rigor does not read suppression " \
848
+ "comments out of `.rbs` or `.rigor.yml`. Fixing the spelling is the real fix.",
849
+ severity_authored: :info,
850
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :warning },
851
+ # Syntactic and author-directed at once: the token is provably outside the vocabulary, and
852
+ # the author wrote the declaration that names it. The intent gate is what keeps an open
853
+ # vocabulary from turning into noise.
854
+ evidence_tier: :high,
855
+ since: "0.3.4"
856
+ ),
857
+
858
+ CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED => Entry.new(
859
+ id: CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED,
860
+ summary: "Effect annotations are present, but no `effects:` block enables anything that " \
861
+ "reads them.",
862
+ fires_when: [
863
+ "The project's own `signature_paths:` RBS carries a `%a{pure}` or " \
864
+ "`%a{rigor:v1:effect …}` annotation.",
865
+ "`.rigor.yml` carries no `effects:` block, so effect collection never runs and nothing " \
866
+ "checks those annotations. One diagnostic per run, positioned at the first such " \
867
+ "annotation."
868
+ ],
869
+ does_not_fire_when: [
870
+ "An `effects:` block is present — including `effects: {}` and `effects: {check: false}`, " \
871
+ "both of which are deliberate answers to the question this asks.",
872
+ "No project-authored effect annotation exists.",
873
+ "The annotation is written only as an rbs-inline `# @rbs %a{…}` comment and this run " \
874
+ "did not already have an RBS environment at hand: detecting it would mean building one " \
875
+ "on a surface that must stay free, so the residual stays quiet rather than paying for " \
876
+ "an `:info`."
877
+ ],
878
+ suppression: "Add an `effects:` block (`effects: {}` enables collection), or " \
879
+ "`disable: [\"effect.annotations-unchecked\"]` in `.rigor.yml` to keep the " \
880
+ "annotations documentary.",
881
+ severity_authored: :info,
882
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :info },
883
+ # Purely a presence report about the project's own files — nothing is inferred, so there is
884
+ # no proof to be wrong about.
885
+ evidence_tier: nil,
886
+ since: "0.3.4"
887
+ ),
888
+
731
889
  CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
732
890
  id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
733
891
  summary: "A comment uses a suppression marker Rigor does not recognise " \
@@ -37,6 +37,17 @@ module Rigor
37
37
 
38
38
  RUN_DIAGNOSTICS_PRODUCER_ID = "analysis.run-diagnostics"
39
39
 
40
+ # ADR-103 WD13 / issue #382 — the whole-run **effects sidecar**: the run's per-file effect
41
+ # collections, keyed by {Effects::Identity.descriptor} (this key descriptor plus the vocabulary
42
+ # version, the catalogue identity and the `effects:` digest) rather than by the descriptor above.
43
+ #
44
+ # A separate producer id, not a second section of the diagnostics entry, and that is the whole of
45
+ # "the diagnostics slot is never invalidated by effects": the two slots cannot share a fate when they
46
+ # do not share a file. It also keeps the ADR-87 boot-slim probe reading exactly the bytes it reads
47
+ # today — it peeks `analysis.run-diagnostics` and finds a plain diagnostics array, whatever a
48
+ # collecting run wrote elsewhere.
49
+ RUN_EFFECTS_PRODUCER_ID = "analysis.run-effects"
50
+
40
51
  # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
41
52
  # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
42
53
  # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
@@ -47,6 +58,11 @@ module Rigor
47
58
  # often an evicted generation is asked for again — see issue #151.
48
59
  GENERATION_CAP = 16
49
60
 
61
+ # The effects sidecar's own compaction budget. One generation per (path set × effects identity), and
62
+ # only a project that opted in writes any at all, so it is sized as the diagnostics cap's shadow: a
63
+ # collecting project's path-set churn is the same churn, and over-evicting costs one recompute.
64
+ EFFECTS_GENERATION_CAP = GENERATION_CAP
65
+
50
66
  # @param rbs_config_entries [Array<Cache::Descriptor::ConfigEntry>] the RBS-derived config slots
51
67
  # (`rbs.libraries` [+ `rbs.virtual_rbs`]). nil on any failure so a malformed key disables the cache.
52
68
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Runner
6
+ # Where an `effect.unknown-label` finding is positioned (ADR-103 WD14; #384).
7
+ #
8
+ # The declaration, not the method: this is a fact about what the author wrote, and the fix is on
9
+ # that line. A `.rbs` position is the right answer here even though its sibling deliberately avoids
10
+ # one (the `rbs_extended.unsatisfied-conformance` precedent) — there is no Ruby `def` that could
11
+ # carry the typo. A value written in configuration has no location at all and lands at
12
+ # `.rigor.yml:1`, the `rbs.coverage.quarantined-signature` precedent.
13
+ module DeclarationPosition
14
+ CONFIG_PATH = ".rigor.yml"
15
+ RUBY_EXTENSION = ".rb"
16
+ private_constant :RUBY_EXTENSION
17
+
18
+ module_function
19
+
20
+ # @param finding [#location, #spelling]
21
+ # @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path
22
+ # @return [Array(String, Integer)] `[path, line]`
23
+ def of(finding, sources: {})
24
+ location = finding.location
25
+ return [CONFIG_PATH, 1] if location.nil?
26
+
27
+ path, _, raw_line = location.rpartition(":")
28
+ return [CONFIG_PATH, 1] if path.empty?
29
+
30
+ line = raw_line.to_i
31
+ line = 1 unless line.positive?
32
+ return [path, line] unless path.end_with?(RUBY_EXTENSION)
33
+
34
+ [path, inline_line(path, finding.spelling, sources) || line]
35
+ end
36
+
37
+ # rbs-inline's writer re-emits the author's own comment block ABOVE the annotation it generates,
38
+ # so a line number read out of the synthesized buffer drifts from the `.rb` line the author
39
+ # actually wrote — by the length of every method body above it. The annotation's own text is
40
+ # unique enough to find again, so the Ruby file is what answers. One read, and only when a
41
+ # finding already exists.
42
+ def inline_line(path, spelling, sources)
43
+ return nil if spelling.nil?
44
+
45
+ source = sources[path] || File.read(path)
46
+ source.each_line.with_index(1) { |line, number| return number if line.include?(spelling) }
47
+ nil
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diagnostic"
4
+ require_relative "../check_rules"
5
+ require_relative "../../effects/signature_sources"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ class Runner
10
+ # `effect.annotations-unchecked` — the ADR-103 WD13 commitment-1 residual (#384).
11
+ #
12
+ # WD13 fixes two rules that pull against each other: an annotation **must not** turn effect
13
+ # collection on (a `%a{pure}` written for Steep's benefit would otherwise impose a project-wide
14
+ # cost cliff on every run of every project that has one), and an annotation **must not** be
15
+ # silently inert either. One `:info` per run is the whole reconciliation: the declarations are
16
+ # there, nothing reads them, and `effects: {}` is the one-line answer.
17
+ #
18
+ # ## What it is allowed to cost
19
+ #
20
+ # This runs on the surface that is meant to be free — a project with no `effects:` block, which
21
+ # is every project by default — so the budget is a glob and a regex:
22
+ #
23
+ # - It reads the project's own `signature_paths:` `.rbs` tree (the same stratum the envelope
24
+ # check reads) and matches {Effects::SignatureSources::ANNOTATION_HINT} line by line. No RBS
25
+ # parse, no analysis, no environment build, and nothing at all when the tree does not exist.
26
+ # - It consults the run's virtual RBS — rbs-inline's `# @rbs %a{…}` — only when the run ALREADY
27
+ # resolved an environment. Building one here to find an `:info` would cost more than the
28
+ # `:info` is worth, so an inline-only annotation is reported when the run happens to have a
29
+ # loader at hand and not otherwise. Under-reporting is the fail-quiet direction; the check
30
+ # itself never depends on this pass.
31
+ #
32
+ # It is computed OUTSIDE the cached run assembly for the same reason `EffectEnvelopePass` is:
33
+ # the `effects:` block is deliberately absent from the diagnostics cache identity, so a residual
34
+ # baked into that entry would survive the very edit that answers it.
35
+ class EffectAnnotationResidualPass
36
+ NO_DIAGNOSTICS = [].freeze
37
+ private_constant :NO_DIAGNOSTICS
38
+
39
+ RULE = CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED
40
+
41
+ RUBY_EXTENSION = ".rb"
42
+ private_constant :RUBY_EXTENSION
43
+
44
+ MESSAGE = "Effect annotations (`%a{pure}` / `%a{rigor:v1:effect …}`) are present in your " \
45
+ "project's signatures, but `.rigor.yml` carries no `effects:` block, so effect " \
46
+ "collection never runs and nothing checks them — they are documentation, not a " \
47
+ "contract. Add `effects: {}` to have Rigor prove what your methods do and check " \
48
+ "these bounds against it (ADR-103); an annotation alone never turns collection " \
49
+ "on, because that would make one line in one signature file more expensive for " \
50
+ "every run of the project."
51
+ private_constant :MESSAGE
52
+
53
+ # @param configuration [Rigor::Configuration]
54
+ # @param rbs_loader [Rigor::Environment::RbsLoader, nil] a loader the run ALREADY has; never
55
+ # one built for this pass. `nil` simply drops the virtual-RBS stratum.
56
+ def initialize(configuration:, rbs_loader: nil)
57
+ @configuration = configuration
58
+ @rbs_loader = rbs_loader
59
+ end
60
+
61
+ # @return [Array<Diagnostic>] zero or one.
62
+ def diagnostics
63
+ return NO_DIAGNOSTICS if @configuration.effects_enabled?
64
+ return NO_DIAGNOSTICS if @configuration.disabled_rules.include?(RULE)
65
+
66
+ sources = Effects::SignatureSources.collect(
67
+ signature_paths: @configuration.signature_paths, virtual_rbs: @rbs_loader&.virtual_rbs
68
+ )
69
+ return NO_DIAGNOSTICS if sources.empty?
70
+
71
+ found = Effects::SignatureSources.first_annotated(sources)
72
+ found.nil? ? NO_DIAGNOSTICS : [build_diagnostic(found)]
73
+ rescue StandardError
74
+ # Fail-soft, like every other effects surface: an advisory `:info` must never fail a run.
75
+ NO_DIAGNOSTICS
76
+ end
77
+
78
+ private
79
+
80
+ # Positioned at the first annotation itself rather than at `.rigor.yml:1`: the fix is a config
81
+ # edit, but the thing being reported is something the author wrote, and pointing at it is what
82
+ # tells them WHICH declaration is inert.
83
+ def build_diagnostic(found)
84
+ name, content, line = found
85
+ path = Effects::SignatureSources.source_path(name)
86
+ line = inline_line(path, content, line) if path.end_with?(RUBY_EXTENSION)
87
+ Diagnostic.new(
88
+ path: path, line: line, column: 1, message: MESSAGE,
89
+ severity: :info, rule: RULE, source_family: :builtin
90
+ )
91
+ end
92
+
93
+ # A `virtual:` buffer's line numbers are the synthesized RBS's, not the `.rb`'s — rbs-inline
94
+ # re-emits the author's comment block above each generated member, so the two drift apart by
95
+ # the length of every body above. Find the annotation's own text in the Ruby file instead.
96
+ def inline_line(path, content, line)
97
+ spelling = content.each_line.to_a[line - 1].to_s[/%a\{[^}]*\}/]
98
+ return line if spelling.nil?
99
+
100
+ File.foreach(path).with_index(1) { |source_line, number| return number if source_line.include?(spelling) }
101
+ line
102
+ rescue StandardError
103
+ line
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end