rigortype 0.3.4 → 0.3.6
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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +15 -9
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effect_attribution"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
# One entry of a plugin's `effect_ancestry:` — an ancestry edge the plugin's own gem introduces and
|
|
8
|
+
# the project's source never writes down (ADR-103 WD17; [#465](https://github.com/rigortype/rigor/issues/465)).
|
|
9
|
+
#
|
|
10
|
+
# `Effects::PluginFacts` walks the project's own `class … <` and `include` lines and nothing else,
|
|
11
|
+
# deliberately: reading the RBS ancestor chain instead would make a row's reach a function of whether
|
|
12
|
+
# anyone happened to run `rbs prototype`. The cost is that a chain which leaves project source never
|
|
13
|
+
# comes back. `class UserMailer < Devise::Mailer` is the measured case — `Devise::Mailer <
|
|
14
|
+
# ActionMailer::Base` is a line in the devise gem, so `rigor-actionmailer`'s rows stop one step short
|
|
15
|
+
# of every mailer a Devise application writes, and the five `Auth::*Controller < Devise::*Controller`
|
|
16
|
+
# subclasses beside it are entry points.
|
|
17
|
+
#
|
|
18
|
+
# A plugin that models a gem knows that gem's own inheritance. Declaring it here keeps the rule that
|
|
19
|
+
# Rigor reads *declarations* rather than RBS: the declaration simply comes from the plugin instead of
|
|
20
|
+
# from project source.
|
|
21
|
+
#
|
|
22
|
+
# ## What a claim may say
|
|
23
|
+
#
|
|
24
|
+
# `parent:` need only be a **true ancestor**, not the immediate superclass. The sole use of the
|
|
25
|
+
# ancestry is to make a row reachable, and no plugin row is ever keyed on a project class, so
|
|
26
|
+
# skipping intermediate links loses nothing — while insisting on the immediate parent would force a
|
|
27
|
+
# claim that is sometimes false: `Devise::SessionsController`'s real parent is `DeviseController`,
|
|
28
|
+
# whose own parent is `Devise.parent_controller` and therefore configurable per project. A claim that
|
|
29
|
+
# skips links MUST say so in its `why:`.
|
|
30
|
+
#
|
|
31
|
+
# ## Who may make one
|
|
32
|
+
#
|
|
33
|
+
# Bundled plugins only ({Rigor::Plugin::FirstParty}), enforced in `PluginFacts`. An ancestry claim
|
|
34
|
+
# carries no labels of its own, so it looks harmless — but it makes *other* plugins' rows reachable,
|
|
35
|
+
# and a third-party plugin asserting `Foo < ActiveRecord::Base` would pull rigor-activerecord's
|
|
36
|
+
# first-party discharging rows onto `Foo`. The `effect_root:` demotion and the `discharge:` grant
|
|
37
|
+
# both answer their own version of that question the same way.
|
|
38
|
+
class EffectAncestry
|
|
39
|
+
attr_reader :child, :parent, :why
|
|
40
|
+
|
|
41
|
+
def initialize(child:, parent:, why:)
|
|
42
|
+
@child = validate_class_name!(child, "child")
|
|
43
|
+
@parent = validate_class_name!(parent, "parent")
|
|
44
|
+
@why = validate_why!(why)
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ "child" => @child, "parent" => @parent }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ==(other)
|
|
53
|
+
other.is_a?(EffectAncestry) && to_h == other.to_h
|
|
54
|
+
end
|
|
55
|
+
alias eql? ==
|
|
56
|
+
|
|
57
|
+
def hash
|
|
58
|
+
to_h.hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_class_name!(value, role)
|
|
64
|
+
name = value.to_s
|
|
65
|
+
unless EffectAttribution::CLASS_NAME.match?(name)
|
|
66
|
+
raise ArgumentError, "effect ancestry #{role} must be a class name, got #{value.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
name.dup.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_why!(why)
|
|
73
|
+
value = why.to_s
|
|
74
|
+
raise ArgumentError, "effect ancestry #{@child} < #{@parent} needs a `why:` justification" if value.empty?
|
|
75
|
+
|
|
76
|
+
value.dup.freeze
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -5,6 +5,7 @@ require_relative "protocol_contract"
|
|
|
5
5
|
require_relative "additional_initializer"
|
|
6
6
|
require_relative "effect_attribution"
|
|
7
7
|
require_relative "effect_edge"
|
|
8
|
+
require_relative "effect_ancestry"
|
|
8
9
|
require_relative "effect_entry_points"
|
|
9
10
|
require_relative "first_party"
|
|
10
11
|
|
|
@@ -46,7 +47,8 @@ module Rigor
|
|
|
46
47
|
:heredoc_templates, :nested_class_templates, :trait_registries,
|
|
47
48
|
:hkt_registrations, :hkt_definitions, :signature_paths, :protocol_contracts,
|
|
48
49
|
:source_rbs_synthesizer, :additional_initializers,
|
|
49
|
-
:effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points
|
|
50
|
+
:effect_root, :effect_labels, :effect_attributions, :effect_edges, :effect_entry_points,
|
|
51
|
+
:effect_ancestry
|
|
50
52
|
|
|
51
53
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
52
54
|
id:, version:,
|
|
@@ -57,7 +59,7 @@ module Rigor
|
|
|
57
59
|
hkt_registrations: [], hkt_definitions: [], signature_paths: [], protocol_contracts: [],
|
|
58
60
|
source_rbs_synthesizer: nil, additional_initializers: [],
|
|
59
61
|
effect_root: nil, effect_labels: [], effect_attributions: [], effect_edges: [],
|
|
60
|
-
effect_entry_points: []
|
|
62
|
+
effect_entry_points: [], effect_ancestry: []
|
|
61
63
|
)
|
|
62
64
|
validate_id!(id)
|
|
63
65
|
validate_version!(version)
|
|
@@ -76,11 +78,8 @@ module Rigor
|
|
|
76
78
|
validate_protocol_contracts!(protocol_contracts)
|
|
77
79
|
validate_source_rbs_synthesizer!(source_rbs_synthesizer)
|
|
78
80
|
validate_additional_initializers!(additional_initializers)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
validate_effect_attributions!(effect_attributions)
|
|
82
|
-
validate_effect_edges!(effect_edges)
|
|
83
|
-
validate_effect_entry_points!(effect_entry_points)
|
|
81
|
+
validate_effect_fields!(effect_root, effect_labels, effect_attributions, effect_edges,
|
|
82
|
+
effect_entry_points, effect_ancestry)
|
|
84
83
|
|
|
85
84
|
assign_fields(id, version, description, config_schema, produces, consumes, owns_receivers,
|
|
86
85
|
open_receivers, type_node_resolvers, block_as_methods, heredoc_templates, trait_registries,
|
|
@@ -88,7 +87,8 @@ module Rigor
|
|
|
88
87
|
source_rbs_synthesizer)
|
|
89
88
|
assign_nested_class_templates(nested_class_templates)
|
|
90
89
|
assign_additional_initializers(additional_initializers)
|
|
91
|
-
assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
|
|
90
|
+
assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
|
|
91
|
+
effect_entry_points, effect_ancestry)
|
|
92
92
|
freeze
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -135,12 +135,26 @@ module Rigor
|
|
|
135
135
|
|
|
136
136
|
# ADR-103 WD2 / WD6 / WD10 / WD14 — the effect contract's five fields, assigned together and outside
|
|
137
137
|
# `assign_fields` (which already carries the maximum positional arity).
|
|
138
|
-
|
|
138
|
+
# The six `effect_*` fields validate together, so {#initialize} carries one call rather than six.
|
|
139
|
+
def validate_effect_fields!(effect_root, effect_labels, effect_attributions, effect_edges,
|
|
140
|
+
effect_entry_points, effect_ancestry)
|
|
141
|
+
validate_effect_root!(effect_root)
|
|
142
|
+
validate_effect_labels!(effect_labels)
|
|
143
|
+
validate_effect_attributions!(effect_attributions)
|
|
144
|
+
validate_effect_edges!(effect_edges)
|
|
145
|
+
validate_effect_entry_points!(effect_entry_points)
|
|
146
|
+
validate_effect_ancestry!(effect_ancestry)
|
|
147
|
+
end
|
|
148
|
+
private :validate_effect_fields!
|
|
149
|
+
|
|
150
|
+
def assign_effect_fields(effect_root, effect_labels, effect_attributions, effect_edges,
|
|
151
|
+
effect_entry_points, effect_ancestry)
|
|
139
152
|
@effect_root = effect_root.nil? ? nil : effect_root.to_s.dup.freeze
|
|
140
153
|
@effect_labels = effect_labels.map { |label| label.to_s.dup.freeze }.uniq.sort.freeze
|
|
141
154
|
@effect_attributions = effect_attributions.dup.freeze
|
|
142
155
|
@effect_edges = effect_edges.dup.freeze
|
|
143
156
|
@effect_entry_points = effect_entry_points.dup.freeze
|
|
157
|
+
@effect_ancestry = effect_ancestry.dup.freeze
|
|
144
158
|
end
|
|
145
159
|
private :assign_effect_fields
|
|
146
160
|
# rubocop:enable Metrics/ParameterLists, Metrics/AbcSize
|
|
@@ -194,7 +208,8 @@ module Rigor
|
|
|
194
208
|
"effect_labels" => effect_labels,
|
|
195
209
|
"effect_attributions" => effect_attributions.map(&:to_h),
|
|
196
210
|
"effect_edges" => effect_edges.map(&:to_h),
|
|
197
|
-
"effect_entry_points" => effect_entry_points.map(&:to_h)
|
|
211
|
+
"effect_entry_points" => effect_entry_points.map(&:to_h),
|
|
212
|
+
"effect_ancestry" => effect_ancestry.map(&:to_h)
|
|
198
213
|
}
|
|
199
214
|
end
|
|
200
215
|
|
|
@@ -506,6 +521,15 @@ module Rigor
|
|
|
506
521
|
end
|
|
507
522
|
end
|
|
508
523
|
|
|
524
|
+
# ADR-103 WD17 (#465) — `effect_ancestry:` states an ancestry edge the plugin's own gem introduces
|
|
525
|
+
# and the project's source never writes. Each entry MUST be a {Rigor::Plugin::EffectAncestry}; only
|
|
526
|
+
# a bundled plugin's survives, which `Effects::PluginFacts` enforces.
|
|
527
|
+
def validate_effect_ancestry!(entries)
|
|
528
|
+
validate_array_of!("effect_ancestry", entries, "Rigor::Plugin::EffectAncestry instances") do |e|
|
|
529
|
+
e.is_a?(EffectAncestry)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
509
533
|
# ADR-103 WD14 — `effect_entry_points:` names the `effects.snapshot.reach:` presets this plugin
|
|
510
534
|
# supplies. Each entry MUST be a {Rigor::Plugin::EffectEntryPoints}.
|
|
511
535
|
def validate_effect_entry_points!(entries)
|
|
@@ -262,9 +262,15 @@ module Rigor
|
|
|
262
262
|
# plugin may open, and `discharge_allowed`, whether its attributions may discharge — so nothing
|
|
263
263
|
# downstream has to re-derive who is first-party.
|
|
264
264
|
Contribution = Data.define(:id, :owner, :requested_root, :discharge_allowed, :labels, :attributions,
|
|
265
|
-
:edges, :entry_points) do
|
|
265
|
+
:edges, :entry_points, :ancestry) do
|
|
266
|
+
# `ancestry:` defaults so a caller written before #465 — and every spec that constructs one by
|
|
267
|
+
# hand — keeps working; a plugin that declares none contributes none.
|
|
268
|
+
def initialize(ancestry: [], **rest)
|
|
269
|
+
super
|
|
270
|
+
end
|
|
271
|
+
|
|
266
272
|
def empty?
|
|
267
|
-
labels.empty? && attributions.empty? && edges.empty? && entry_points.empty?
|
|
273
|
+
labels.empty? && attributions.empty? && edges.empty? && entry_points.empty? && ancestry.empty?
|
|
268
274
|
end
|
|
269
275
|
end
|
|
270
276
|
|
|
@@ -422,7 +428,8 @@ module Rigor
|
|
|
422
428
|
id: manifest.id, owner: manifest.effect_owner, requested_root: manifest.effect_root,
|
|
423
429
|
discharge_allowed: manifest.effect_discharge_allowed?,
|
|
424
430
|
labels: plugin.effect_labels, attributions: plugin.effect_attributions,
|
|
425
|
-
edges: plugin.effect_edges, entry_points: plugin.effect_entry_points
|
|
431
|
+
edges: plugin.effect_edges, entry_points: plugin.effect_entry_points,
|
|
432
|
+
ancestry: plugin.effect_ancestry
|
|
426
433
|
)
|
|
427
434
|
contribution.empty? ? nil : contribution
|
|
428
435
|
rescue StandardError
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rbs"
|
|
4
4
|
|
|
5
|
+
require_relative "../effects/signature_sources"
|
|
5
6
|
require_relative "../rbs_extended"
|
|
6
7
|
require_relative "reporter"
|
|
7
8
|
|
|
@@ -50,11 +51,19 @@ module Rigor
|
|
|
50
51
|
# `.rbs` files, plus the virtual entries rbs-inline and plugin `source_rbs` synthesis contribute.
|
|
51
52
|
# @param registry [Rigor::Effects::Registry] the vocabulary an unknown label is judged against.
|
|
52
53
|
# @return [Result]
|
|
54
|
+
#
|
|
55
|
+
# The `ANNOTATION_HINT` routing test runs here, before any parse: a source with no honoured
|
|
56
|
+
# payload can contribute neither an envelope nor an unresolved report, so a signature tree with
|
|
57
|
+
# no effect annotation is answered by one regex per file — which is what that hint's own
|
|
58
|
+
# documentation promises, and what every warm envelope-lane run was paying a full
|
|
59
|
+
# `RBS::Parser.parse_signature` per file for.
|
|
53
60
|
def scan(sources:, registry:)
|
|
54
61
|
reporter = Reporter.new
|
|
55
62
|
methods = {}
|
|
56
63
|
classes = {}
|
|
57
64
|
sources.each do |name, content|
|
|
65
|
+
next unless Effects::SignatureSources::ANNOTATION_HINT.match?(content)
|
|
66
|
+
|
|
58
67
|
declarations(name, content).each do |decl|
|
|
59
68
|
walk(decl, [], methods, classes, registry, reporter)
|
|
60
69
|
end
|
data/lib/rigor/rbs_extended.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "type"
|
|
|
4
4
|
require_relative "builtins/imported_refinements"
|
|
5
5
|
require_relative "flow_contribution"
|
|
6
6
|
require_relative "effects/envelope"
|
|
7
|
+
require_relative "effects/inline_anchor"
|
|
7
8
|
require_relative "effects/label"
|
|
8
9
|
require_relative "effects/label_set"
|
|
9
10
|
require_relative "effects/signature_sources"
|
|
@@ -769,6 +770,12 @@ module Rigor
|
|
|
769
770
|
|
|
770
771
|
# `path:line` for the annotation, project-relative when it sits under the working directory, so a
|
|
771
772
|
# diagnostic message names `sig/foo.rbs:12` rather than an absolute path.
|
|
773
|
+
#
|
|
774
|
+
# A synthesized buffer is re-anchored here rather than at any of the surfaces that render it
|
|
775
|
+
# ({Effects::InlineAnchor}; #432). The buffer's own line numbers describe a document the author
|
|
776
|
+
# never saw, so an envelope carrying one is wrong for every consumer at once — the diagnostic, the
|
|
777
|
+
# `effect.unknown-label` position, `rigor explain`, the JSON formatter, LSP hover. Correcting it
|
|
778
|
+
# where the value is *built* is what makes them agree without each learning the mapping.
|
|
772
779
|
def render_annotation_location(annotation)
|
|
773
780
|
location = annotation_location(annotation)
|
|
774
781
|
return nil if location.nil?
|
|
@@ -777,12 +784,25 @@ module Rigor
|
|
|
777
784
|
name = buffer.respond_to?(:name) ? buffer.name.to_s : nil
|
|
778
785
|
return nil if name.nil? || name.empty?
|
|
779
786
|
|
|
780
|
-
|
|
781
|
-
"#{
|
|
787
|
+
path = relative_annotation_path(name)
|
|
788
|
+
"#{path}:#{annotation_line(annotation, location, buffer, path)}"
|
|
782
789
|
rescue StandardError
|
|
783
790
|
nil
|
|
784
791
|
end
|
|
785
792
|
|
|
793
|
+
# The line a reader can open. For a real `.rbs` that is the parser's own answer; for the
|
|
794
|
+
# `virtual:rbs-inline:…rb` buffer the writer produced, the annotation is found again in the Ruby
|
|
795
|
+
# source by its own spelling.
|
|
796
|
+
def annotation_line(annotation, location, buffer, path)
|
|
797
|
+
line = location.respond_to?(:start_line) ? location.start_line : 1
|
|
798
|
+
content = buffer.respond_to?(:content) ? buffer.content : nil
|
|
799
|
+
return line if content.nil?
|
|
800
|
+
|
|
801
|
+
Effects::InlineAnchor.ruby_line(
|
|
802
|
+
path: path, buffer: content, buffer_line: line, spelling: "%a{#{annotation.string}}"
|
|
803
|
+
)
|
|
804
|
+
end
|
|
805
|
+
|
|
786
806
|
# The buffer-name → readable-path rule lives with the walk that produces the buffers
|
|
787
807
|
# ({Effects::SignatureSources}), so a `virtual:` name is stripped identically wherever it surfaces.
|
|
788
808
|
def relative_annotation_path(name)
|
data/lib/rigor/version.rb
CHANGED
|
@@ -598,8 +598,23 @@ class Date
|
|
|
598
598
|
%a{pure}
|
|
599
599
|
def advance: (untyped options) -> Date
|
|
600
600
|
def all_day: () -> Range[Time]
|
|
601
|
-
|
|
602
|
-
|
|
601
|
+
# NOTE: `Date#to_time` is an OVERLOAD CONTINUATION (`| ...`), not a plain
|
|
602
|
+
# declaration — stdlib `date` already types `to_time: () -> Time`, and a
|
|
603
|
+
# second full declaration of the same method from a second signature source
|
|
604
|
+
# makes `RBS::DefinitionBuilder` raise `DuplicatedMethodDefinitionError`,
|
|
605
|
+
# which collapses `Date` AND `DateTime` to `Dynamic[top]` for every project
|
|
606
|
+
# that activates this plugin (#437 — the same failure the `DateTime#to_time`
|
|
607
|
+
# note below records). The row cannot simply be DROPPED the way
|
|
608
|
+
# `DateTime#to_time` was: ActiveSupport genuinely widens the arity, and
|
|
609
|
+
# without the row `date.to_time(:utc)` — correct Rails code — would draw an
|
|
610
|
+
# arity diagnostic. `| ...` appends this overload ahead of the stdlib one, so
|
|
611
|
+
# both arities resolve and the `%a{pure}` envelope stays attached to the
|
|
612
|
+
# ActiveSupport overload (which is the one a no-argument call selects).
|
|
613
|
+
# `| ...` requires the base declaration to exist, which `Environment.for_project`
|
|
614
|
+
# guarantees: it merges `DEFAULT_LIBRARIES` — including `date` — into every
|
|
615
|
+
# environment that loads a plugin's `signature_paths`.
|
|
616
|
+
%a{pure}
|
|
617
|
+
def to_time: (?Symbol form) -> Time | ...
|
|
603
618
|
end
|
|
604
619
|
|
|
605
620
|
# ---------------------------------------------------------------
|
|
@@ -82,6 +82,33 @@ module Rigor
|
|
|
82
82
|
},
|
|
83
83
|
always_included: ["Devise::Models::Authenticatable"]
|
|
84
84
|
)
|
|
85
|
+
],
|
|
86
|
+
# ADR-103 WD17 (#465) — the ancestry Devise introduces and a project's own source never writes.
|
|
87
|
+
#
|
|
88
|
+
# `class UserMailer < Devise::Mailer` and `class Auth::SessionsController <
|
|
89
|
+
# Devise::SessionsController` are what a Devise application looks like, and the walk that reaches
|
|
90
|
+
# a plugin row climbs the project's own `class … <` lines only — so it stopped at the gem class
|
|
91
|
+
# and `rigor-actionmailer`'s and `rigor-actionpack`'s rows never arrived. Measured on Mastodon:
|
|
92
|
+
# six classes, five of them controllers, which are entry points.
|
|
93
|
+
#
|
|
94
|
+
# Every `parent:` below is a **true ancestor and not the immediate one**, deliberately. Devise's
|
|
95
|
+
# controllers descend through `DeviseController`, whose own parent is `Devise.parent_controller`
|
|
96
|
+
# — configurable per project, defaulting to the application's own `ApplicationController`. Naming
|
|
97
|
+
# the intermediate links would state something a project can make false; naming a true ancestor
|
|
98
|
+
# loses nothing, because no plugin row is ever keyed on a project class.
|
|
99
|
+
effect_ancestry: [
|
|
100
|
+
Rigor::Plugin::EffectAncestry.new(
|
|
101
|
+
child: "Devise::Mailer", parent: "ActionMailer::Base",
|
|
102
|
+
why: "Devise::Mailer is an ActionMailer; a true ancestor, skipping Devise's own intermediates"
|
|
103
|
+
),
|
|
104
|
+
*%w[SessionsController RegistrationsController PasswordsController ConfirmationsController
|
|
105
|
+
UnlocksController OmniauthCallbacksController].map do |controller|
|
|
106
|
+
Rigor::Plugin::EffectAncestry.new(
|
|
107
|
+
child: "Devise::#{controller}", parent: "ActionController::Base",
|
|
108
|
+
why: "a Devise controller is an ActionController; a true ancestor, skipping DeviseController " \
|
|
109
|
+
"and `Devise.parent_controller`, which a project may configure"
|
|
110
|
+
)
|
|
111
|
+
end
|
|
85
112
|
]
|
|
86
113
|
)
|
|
87
114
|
end
|
|
@@ -50,6 +50,19 @@ module Rigor
|
|
|
50
50
|
# space-free spelling is affected; `# :nodoc:` never reaches upstream's annotation grammar.
|
|
51
51
|
RDOC_DIRECTIVE_COMMENT = /\A#:[a-z_][\w-]*:/
|
|
52
52
|
|
|
53
|
+
# An `%a{…}` the author wrote above a `class` / `module`, as upstream's writer echoes it: an ordinary
|
|
54
|
+
# comment, because the writer emits no annotation on a declaration
|
|
55
|
+
# ([#452](https://github.com/rigortype/rigor/issues/452)).
|
|
56
|
+
ECHOED_ANNOTATION = /\A\s*#\s*@rbs\s+(%a\{[^}]*\})\s*\z/
|
|
57
|
+
|
|
58
|
+
# The declaration the pending annotations belong to. `class`/`module` and the whitespace after the
|
|
59
|
+
# keyword, so `class_eval` is not one and the indentation to re-emit at is captured.
|
|
60
|
+
DECLARATION_LINE = /\A(\s*)(?:class|module)\s/
|
|
61
|
+
|
|
62
|
+
COMMENT_LINE = /\A\s*#/
|
|
63
|
+
|
|
64
|
+
private_constant :ECHOED_ANNOTATION, :DECLARATION_LINE, :COMMENT_LINE
|
|
65
|
+
|
|
53
66
|
# @param require_magic_comment [Boolean] when `false` (the default since ADR-93 WD1), the magic
|
|
54
67
|
# comment is not required and the file is processed only if it actually carries an annotation — see
|
|
55
68
|
# {#annotated?}. When `true` (the old ADR-32 WD2 gate), only files opening with
|
|
@@ -82,7 +95,7 @@ module Rigor
|
|
|
82
95
|
return nil if parsed.nil?
|
|
83
96
|
|
|
84
97
|
uses, decls, rbs_decls = parsed
|
|
85
|
-
rendered = ::RBS::Inline::Writer.write(uses, decls, rbs_decls)
|
|
98
|
+
rendered = reattach_declaration_annotations(::RBS::Inline::Writer.write(uses, decls, rbs_decls))
|
|
86
99
|
return nil if rendered.nil? || rendered.strip.empty?
|
|
87
100
|
|
|
88
101
|
notices = unhonoured_annotations(result)
|
|
@@ -96,6 +109,48 @@ module Rigor
|
|
|
96
109
|
|
|
97
110
|
private
|
|
98
111
|
|
|
112
|
+
# Re-attaches a class- or module-level `%a{…}` that upstream's writer dropped (#452).
|
|
113
|
+
#
|
|
114
|
+
# `RBS::Inline::Writer` emits a member's annotations as real annotation lines and a *declaration's*
|
|
115
|
+
# as nothing at all — it echoes the author's comment block above `class Foo` and stops there. So the
|
|
116
|
+
# cheapest bound in the feature, one line above a class, reached the envelope reader as a comment:
|
|
117
|
+
# no bound, no diagnostic, and a clean run that had checked nothing. The `.rbs` lane, writing the
|
|
118
|
+
# same `%a{pure}` above the same `class`, worked. Both lanes now answer the same.
|
|
119
|
+
#
|
|
120
|
+
# The rewrite reads only what the writer itself emitted, in one pass, and is deliberately literal:
|
|
121
|
+
# an annotation the author spelled in a comment block that the writer echoed is re-emitted, at the
|
|
122
|
+
# declaration's own indentation, on the line the RBS grammar wants it on. Three properties make that
|
|
123
|
+
# safe rather than clever:
|
|
124
|
+
#
|
|
125
|
+
# - a comment block is echoed **only when its member is emitted**, so a detached annotation (one a
|
|
126
|
+
# blank line separates from the declaration, which upstream drops) is never echoed and so never
|
|
127
|
+
# re-attached — the two lanes agree about that too;
|
|
128
|
+
# - any non-comment line clears the pending set, so a member's own annotation — which the writer
|
|
129
|
+
# *does* emit, on the line between the echo and the `def` — ends the run before a declaration can
|
|
130
|
+
# claim it, and no annotation is ever attached twice;
|
|
131
|
+
# - the same property makes this forward-compatible: if upstream some day emits the declaration
|
|
132
|
+
# annotation itself, that line clears the pending set and this contributes nothing.
|
|
133
|
+
def reattach_declaration_annotations(rendered)
|
|
134
|
+
return rendered if rendered.nil? || !rendered.include?("%a{")
|
|
135
|
+
|
|
136
|
+
pending = []
|
|
137
|
+
rendered.lines.flat_map do |line|
|
|
138
|
+
if (annotation = ECHOED_ANNOTATION.match(line))
|
|
139
|
+
pending << annotation[1]
|
|
140
|
+
line
|
|
141
|
+
elsif COMMENT_LINE.match?(line)
|
|
142
|
+
line
|
|
143
|
+
elsif (declaration = DECLARATION_LINE.match(line)) && !pending.empty?
|
|
144
|
+
emitted = pending.map { |spelling| "#{declaration[1]}#{spelling}\n" } << line
|
|
145
|
+
pending = []
|
|
146
|
+
emitted
|
|
147
|
+
else
|
|
148
|
+
pending = []
|
|
149
|
+
line
|
|
150
|
+
end
|
|
151
|
+
end.join
|
|
152
|
+
end
|
|
153
|
+
|
|
99
154
|
# True when the file carries at least one rbs-inline annotation. Gates the magic-comment-free mode,
|
|
100
155
|
# and is the difference between "honour annotations wherever they are" and "fabricate signatures for
|
|
101
156
|
# code nobody annotated" — upstream's opt-out mode does the latter, emitting a full
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Sidekiq < Rigor::Plugin::Base
|
|
8
|
+
# rigor-sidekiq's effect contract (ADR-103 WD4 / WD10; [#456](https://github.com/rigortype/rigor/issues/456)).
|
|
9
|
+
#
|
|
10
|
+
# `job.enqueue` shipped in the vocabulary with nothing producing it. Across two Rails applications
|
|
11
|
+
# the corpus census found zero instances of it, while Mastodon alone writes 219 `perform_async`-shaped
|
|
12
|
+
# call sites with this plugin loaded — so the label a policy would actually name ("nothing on this
|
|
13
|
+
# path may enqueue work") could not be written against anything.
|
|
14
|
+
#
|
|
15
|
+
# ## Why the rows key on a module
|
|
16
|
+
#
|
|
17
|
+
# A Sidekiq worker has no base class: it is `class TriggerWebhookWorker; include Sidekiq::Job`. Every
|
|
18
|
+
# other first-party plugin's rows key on a base class and reach a project subclass through the
|
|
19
|
+
# project's own `class … <` lines, which is a walk that cannot see an `include` — so until #456 taught
|
|
20
|
+
# `Effects::PluginFacts#ancestry` to walk included modules beside the superclass, no spelling of these
|
|
21
|
+
# rows could have matched. The marker modules are the plugin's existing `worker_marker_modules:`
|
|
22
|
+
# config, so a project that includes its own concern instead names it once and gets both the arity
|
|
23
|
+
# check and these rows.
|
|
24
|
+
#
|
|
25
|
+
# ## What is deliberately not here
|
|
26
|
+
#
|
|
27
|
+
# `perform_inline` / `perform_sync` run the job on the caller's stack. They are not an enqueue and
|
|
28
|
+
# must not carry `job.enqueue`; the honest reading is an edge into the worker's own `#perform`, which
|
|
29
|
+
# {Rigor::Plugin::EffectEdge::TARGETS} spells only for the ActiveJob shape today. A row claiming the
|
|
30
|
+
# enqueue for them would be worse than the silence.
|
|
31
|
+
module Effects
|
|
32
|
+
# Redis is the transport, and unlike ActiveJob's adapter there is nothing to read: a project that
|
|
33
|
+
# loads Sidekiq has chosen it. `io.net` rather than `io` because the round trip is a socket in every
|
|
34
|
+
# deployment worth naming — a unix socket is still `io.net`'s subsystem, not the filesystem's.
|
|
35
|
+
TRANSPORT = ["io.net"].freeze
|
|
36
|
+
|
|
37
|
+
# The meaning half, and the reason this file exists: what a policy names.
|
|
38
|
+
MEANING = ["job.enqueue"].freeze
|
|
39
|
+
|
|
40
|
+
LABELS = (TRANSPORT + MEANING).freeze
|
|
41
|
+
|
|
42
|
+
# The class-side enqueues. `perform_bulk` takes an array of argument arrays and enqueues each.
|
|
43
|
+
SINGLETON_ENQUEUES = %w[perform_async perform_in perform_at perform_bulk].freeze
|
|
44
|
+
|
|
45
|
+
# `Worker.set(queue: "low").perform_async(…)` — `set` returns a lazy `Setter` that has enqueued
|
|
46
|
+
# nothing, so the enqueue is one link further out, keyed on the class that produced the Setter.
|
|
47
|
+
RESULT_ENQUEUES = %w[perform_async perform_in perform_at perform_bulk].freeze
|
|
48
|
+
|
|
49
|
+
WHY = "Sidekiq's enqueue is a Redis round trip; `job.enqueue` is the meaning a policy names. " \
|
|
50
|
+
"The row is keyed on the marker module because a Sidekiq worker includes rather than " \
|
|
51
|
+
"inherits."
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
# @param marker_modules [Array<String>] the modules a worker includes — the plugin's own
|
|
56
|
+
# `worker_marker_modules:` config, so one project setting drives the arity check and these rows.
|
|
57
|
+
def attributions(marker_modules)
|
|
58
|
+
marker_modules.flat_map do |marker|
|
|
59
|
+
SINGLETON_ENQUEUES.map do |selector|
|
|
60
|
+
EffectAttribution.new(receiver: marker, method: selector, labels: LABELS, singleton: true,
|
|
61
|
+
discharge: true, why: WHY)
|
|
62
|
+
end +
|
|
63
|
+
RESULT_ENQUEUES.map do |selector|
|
|
64
|
+
EffectAttribution.new(
|
|
65
|
+
receiver: marker, method: selector, labels: LABELS, on_result: true, discharge: true,
|
|
66
|
+
why: "#{WHY} Matched on the RESULT of a call to the worker class, which is the shape " \
|
|
67
|
+
"`Worker.set(queue: \"low\").perform_async(…)` takes."
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "sidekiq/worker_index"
|
|
|
6
6
|
require_relative "sidekiq/worker_discoverer"
|
|
7
7
|
require_relative "sidekiq/schedule_scan"
|
|
8
8
|
require_relative "sidekiq/analyzer"
|
|
9
|
+
require_relative "sidekiq/effects"
|
|
9
10
|
|
|
10
11
|
module Rigor
|
|
11
12
|
module Plugin
|
|
@@ -62,9 +63,20 @@ module Rigor
|
|
|
62
63
|
# these are file paths, not directories, because a schedule is a named document rather than a tree.
|
|
63
64
|
"schedule_paths" => { kind: :array, default: ["config/schedule.yml", "config/sidekiq.yml"] }
|
|
64
65
|
},
|
|
65
|
-
produces: [:reachability_roots]
|
|
66
|
+
produces: [:reachability_roots],
|
|
67
|
+
# ADR-103 WD4 / WD10 (#456). The rows themselves are NOT here: they key on the project's own
|
|
68
|
+
# `worker_marker_modules:` setting, so they are built in `#effect_attributions` below.
|
|
69
|
+
effect_labels: []
|
|
66
70
|
)
|
|
67
71
|
|
|
72
|
+
# ADR-103 WD10 — the enqueue rows, keyed on whichever modules this project's workers include.
|
|
73
|
+
# Overridden rather than declared on the manifest because the marker set is configuration;
|
|
74
|
+
# `Plugin::Registry#effect_contributions` is lazy and asks once per process, and only on a run with
|
|
75
|
+
# collection on.
|
|
76
|
+
def effect_attributions
|
|
77
|
+
Effects.attributions(@worker_marker_modules || Array(config.fetch("worker_marker_modules")).map(&:to_s))
|
|
78
|
+
end
|
|
79
|
+
|
|
68
80
|
producer :worker_index, watch: -> { [[@worker_search_paths, "**/*.rb"]] } do |_params|
|
|
69
81
|
WorkerDiscoverer.new(
|
|
70
82
|
io_boundary: io_boundary,
|
|
@@ -168,9 +168,9 @@ The exact recipe (the `BUNDLE_GEMFILE` wiring, the Dependabot entry) is in
|
|
|
168
168
|
`rigor docs ci` (ready files: `rigor docs --list manual | grep
|
|
169
169
|
ci-templates`). Web fallback, before Rigor is installed:
|
|
170
170
|
<https://github.com/rigortype/rigor/blob/master/docs/manual/11-ci.md>.
|
|
171
|
-
- [ADR-51](
|
|
171
|
+
- [ADR-51](../../docs/adr/51-ci-diagnostic-output-formats.md)
|
|
172
172
|
— the output-format surface (the severity / identifier contract).
|
|
173
|
-
- [ADR-27](
|
|
173
|
+
- [ADR-27](../../docs/adr/27-tool-distribution-model.md)
|
|
174
174
|
— why Rigor installs standalone and runs in its own job.
|
|
175
175
|
- [reviewdog](https://github.com/reviewdog/reviewdog) /
|
|
176
176
|
[action-setup](https://github.com/reviewdog/action-setup).
|
|
@@ -24,7 +24,7 @@ rigor docs editor-integration
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
(Web fallback, only before Rigor is installed:
|
|
27
|
-
**[Rigor LSP — Editor Integration](
|
|
27
|
+
**[Rigor LSP — Editor Integration](../../docs/manual/09-editor-integration.md)**.)
|
|
28
28
|
This skill is the *workflow* around it (identify the editor → apply the
|
|
29
29
|
manual's snippet → verify), so it does not duplicate (and cannot
|
|
30
30
|
stale-out) the config details.
|
|
@@ -62,7 +62,7 @@ Every editor snippet simply launches **`rigor lsp`** (stdio) and needs
|
|
|
62
62
|
**`rigor` on the editor's `PATH`** — the same executable `rigor check`
|
|
63
63
|
uses. For GUI editors that do not inherit your shell, the `mise` shim
|
|
64
64
|
path is the most reliable channel (see `rigor docs install`, or
|
|
65
|
-
[Installing Rigor](
|
|
65
|
+
[Installing Rigor](../../docs/install.md)
|
|
66
66
|
on the web). Do **not** add `rigortype` to the project's `Gemfile` — it
|
|
67
67
|
is a tool, not a library.
|
|
68
68
|
|
|
@@ -26,7 +26,7 @@ rigor docs mcp-server
|
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
(Web fallback, only before Rigor is installed:
|
|
29
|
-
**[Rigor MCP Server — AI Agent Integration](
|
|
29
|
+
**[Rigor MCP Server — AI Agent Integration](../../docs/manual/10-mcp-server.md)**.)
|
|
30
30
|
This skill is the *workflow* around it (identify the client → apply the
|
|
31
31
|
manual's snippet → verify the handshake), so it does not duplicate (and
|
|
32
32
|
cannot stale-out) the config details.
|
|
@@ -70,7 +70,7 @@ Every client config simply launches **`rigor mcp`** (stdio) and needs
|
|
|
70
70
|
**`rigor` on the agent's `PATH`** — the same executable `rigor check`
|
|
71
71
|
uses. For agents that do not inherit your shell, the `mise` shim path is
|
|
72
72
|
the most reliable channel (see `rigor docs install`, or
|
|
73
|
-
[Installing Rigor](
|
|
73
|
+
[Installing Rigor](../../docs/install.md)
|
|
74
74
|
on the web). Do **not** add `rigortype` to the project's `Gemfile` — it
|
|
75
75
|
is a tool, not a library.
|
|
76
76
|
|
|
@@ -14,7 +14,7 @@ When a project adds methods to existing classes in its own files — the
|
|
|
14
14
|
classic `lib/core_ext/*.rb` `class String; def squish; …; end` pattern —
|
|
15
15
|
Rigor does not see them unless told to, so every call to the added method
|
|
16
16
|
fires `call.undefined-method` (or `call.unresolved-toplevel` for a
|
|
17
|
-
top-level helper). `pre_eval:` ([ADR-17](
|
|
17
|
+
top-level helper). `pre_eval:` ([ADR-17](../../docs/adr/17-monkey-patch-pre-evaluation.md))
|
|
18
18
|
fixes this: it pre-evaluates the listed project files and registers every
|
|
19
19
|
method they define with a **literal** `def` / `def self.`.
|
|
20
20
|
|
|
@@ -80,14 +80,14 @@ rigor plugin path rigor-<id> # its directory, to browse
|
|
|
80
80
|
The **authoritative** authoring surface — the one this review scores
|
|
81
81
|
against — is the internal spec, not this file:
|
|
82
82
|
|
|
83
|
-
- [`docs/internal-spec/plugin.md`](
|
|
83
|
+
- [`docs/internal-spec/plugin.md`](../../docs/internal-spec/plugin.md)
|
|
84
84
|
— manifest, `node_rule` / `node_file_context`, `dynamic_return` /
|
|
85
85
|
`narrowing_facts`, the `#diagnostic` / `#diagnostics_for` / `.suggest`
|
|
86
86
|
/ `#read_fact` author helpers, `config_schema` `{kind:, default:}`.
|
|
87
|
-
- [`docs/internal-spec/plugin-cache-producers.md`](
|
|
87
|
+
- [`docs/internal-spec/plugin-cache-producers.md`](../../docs/internal-spec/plugin-cache-producers.md)
|
|
88
88
|
— `producer` / `#cache_for` / `#producer_value` / `#producer_error`,
|
|
89
89
|
ADR-60 WD3 record-and-validate.
|
|
90
|
-
- [`docs/internal-spec/plugin-trust.md`](
|
|
90
|
+
- [`docs/internal-spec/plugin-trust.md`](../../docs/internal-spec/plugin-trust.md)
|
|
91
91
|
— `TrustPolicy` / `IoBoundary`.
|
|
92
92
|
|
|
93
93
|
When the checklist below and the spec disagree, **the spec binds** —
|