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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module Reachability
|
|
11
|
+
# ADR-102 — the per-file half of the reference index: one Prism walk that records every constant
|
|
12
|
+
# DECLARATION and every constant REFERENCE, each with the lexical nesting in force at that point.
|
|
13
|
+
#
|
|
14
|
+
# This is deliberately NOT a hook on the typing path. `Reflection.resolve_constant_type` fires only where
|
|
15
|
+
# the engine needs a constant's *type*, so a constant that is read but never typed leaves no trace there —
|
|
16
|
+
# the #345 measurement found five distinct losses (cross-file value constants, parameter defaults,
|
|
17
|
+
# lambda-rvalue bodies, superclass positions, intermediate namespace segments) and produced 140 candidates
|
|
18
|
+
# of which zero were genuine. A reference index has to see every constant node regardless of whether a type
|
|
19
|
+
# was wanted, which is what this walk does.
|
|
20
|
+
#
|
|
21
|
+
# Names are recorded AS WRITTEN together with their nesting; resolution to a fully-qualified name happens
|
|
22
|
+
# in {Graph}, after every file has been scanned, because a bare `Foo` cannot be resolved until the whole
|
|
23
|
+
# declaration set is known.
|
|
24
|
+
module Scan
|
|
25
|
+
# A class / module declaration, or a constant assigned one of the meta-new forms. `nesting` is the
|
|
26
|
+
# enclosing declaration path at the declaration site, so `fqn` is exact.
|
|
27
|
+
Declaration = Data.define(:fqn, :path, :line, :superclass, :includes)
|
|
28
|
+
|
|
29
|
+
# One constant reference. `from` is the fully-qualified name of the innermost enclosing declaration, or
|
|
30
|
+
# nil for a reference written at file level — that is what makes the graph a reachability graph rather
|
|
31
|
+
# than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8).
|
|
32
|
+
Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line)
|
|
33
|
+
|
|
34
|
+
# ADR-102 WD4 — a site where a constant is reached by a mechanism the static reading cannot follow.
|
|
35
|
+
# `name` is the exact constant when the argument is a literal (`"Foo".constantize`), in which case this
|
|
36
|
+
# is as good as a reference. `prefix` is the namespace a dynamic construction can reach into
|
|
37
|
+
# (`"Foo::#{k}".constantize` → `"Foo"`, and `nil` when even that is unknown), which taints every
|
|
38
|
+
# declaration at or below it rather than proving any single one used.
|
|
39
|
+
DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line)
|
|
40
|
+
|
|
41
|
+
Result = Data.define(:declarations, :references, :dynamic_uses)
|
|
42
|
+
|
|
43
|
+
# Roles a referring file can have (ADR-102 WD8). A reference edge carries its referrer's role so
|
|
44
|
+
# "used only by its own test" is a reportable category rather than a bucket boundary.
|
|
45
|
+
def self.role_for(path)
|
|
46
|
+
case path
|
|
47
|
+
when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/ then :test
|
|
48
|
+
when /\.rake\z/, %r{(\A|/)(lib/)?tasks/} then :task
|
|
49
|
+
when %r{(\A|/)config/} then :config
|
|
50
|
+
else :production
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [String] the file's path, as the report should render it.
|
|
55
|
+
# @param source [String] the file's bytes.
|
|
56
|
+
# @param target_ruby [String, nil] Prism version string, threaded from the project configuration.
|
|
57
|
+
# @return [Result, nil] nil when the file does not parse (a parse error is the analyzer's business, not
|
|
58
|
+
# this scan's — it simply contributes nothing rather than half a file).
|
|
59
|
+
# A constant name is ASCII by construction, so a byte sequence that is not valid UTF-8 cannot be one.
|
|
60
|
+
# Dropping it is both correct and the only safe answer: carrying it forward crashed the whole run on
|
|
61
|
+
# the first `String#sub` downstream, which is how this surfaced — `rigor unused` on Rigor's own
|
|
62
|
+
# repository, which vendors a CRuby checkout containing deliberately ill-encoded encoding fixtures.
|
|
63
|
+
def self.usable_name(raw)
|
|
64
|
+
return nil if raw.nil?
|
|
65
|
+
|
|
66
|
+
name = raw.dup.force_encoding(Encoding::UTF_8)
|
|
67
|
+
name.valid_encoding? ? name : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.call(path:, source:, target_ruby: nil)
|
|
71
|
+
parsed = if target_ruby
|
|
72
|
+
Prism.parse(source, filepath: path,
|
|
73
|
+
version: target_ruby)
|
|
74
|
+
else
|
|
75
|
+
Prism.parse(source, filepath: path)
|
|
76
|
+
end
|
|
77
|
+
return nil unless parsed.success?
|
|
78
|
+
|
|
79
|
+
walker = Walker.new(path: path, role: role_for(path))
|
|
80
|
+
walker.walk(parsed.value, [])
|
|
81
|
+
Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
|
|
82
|
+
dynamic_uses: walker.dynamic_uses.freeze)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
|
|
86
|
+
class Walker
|
|
87
|
+
attr_reader :declarations, :references, :dynamic_uses
|
|
88
|
+
|
|
89
|
+
def initialize(path:, role:)
|
|
90
|
+
@path = path
|
|
91
|
+
@role = role
|
|
92
|
+
@declarations = []
|
|
93
|
+
@references = []
|
|
94
|
+
@dynamic_uses = []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def walk(node, nesting)
|
|
98
|
+
return unless node.is_a?(Prism::Node)
|
|
99
|
+
|
|
100
|
+
case node
|
|
101
|
+
when Prism::ClassNode then return walk_declaration(node, nesting, superclass: node.superclass)
|
|
102
|
+
when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil)
|
|
103
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
104
|
+
record_reference(node, nesting)
|
|
105
|
+
# A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
|
|
106
|
+
# and descending would record `A` and `A::B` as references in their own right (22 spurious
|
|
107
|
+
# candidates on Rigor's own lib came from exactly that in the #345 probe).
|
|
108
|
+
return
|
|
109
|
+
when Prism::ConstantWriteNode
|
|
110
|
+
record_meta_new(node, nesting)
|
|
111
|
+
walk(node.value, nesting)
|
|
112
|
+
return
|
|
113
|
+
when Prism::CallNode
|
|
114
|
+
record_dynamic_use(node)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
node.rigor_each_child { |child| walk(child, nesting) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def walk_declaration(node, nesting, superclass:)
|
|
123
|
+
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
124
|
+
return node.rigor_each_child { |child| walk(child, nesting) } if name.nil?
|
|
125
|
+
|
|
126
|
+
fqn = (nesting + [name]).join("::")
|
|
127
|
+
# The superclass position IS a reference — `class Sub < Base` reads `Base` — and it resolves against
|
|
128
|
+
# the OUTER nesting, not inside the body being opened.
|
|
129
|
+
record_reference(superclass, nesting) if superclass
|
|
130
|
+
includes = node.body ? mixin_names(node.body) : []
|
|
131
|
+
@declarations << Declaration.new(fqn: fqn, path: @path, line: node.location.start_line,
|
|
132
|
+
superclass: superclass && Source::ConstantPath.qualified_name(superclass),
|
|
133
|
+
includes: includes.freeze)
|
|
134
|
+
walk(node.body, nesting + [name]) if node.body
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
|
|
138
|
+
# constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
|
|
139
|
+
# for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
|
|
140
|
+
# value rather than a class.
|
|
141
|
+
META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
|
|
142
|
+
private_constant :META_NEW
|
|
143
|
+
|
|
144
|
+
def record_meta_new(node, nesting)
|
|
145
|
+
call = node.value
|
|
146
|
+
return unless call.is_a?(Prism::CallNode)
|
|
147
|
+
|
|
148
|
+
recv = call.receiver
|
|
149
|
+
return unless recv.is_a?(Prism::ConstantReadNode) && META_NEW[recv.name.to_s] == call.name
|
|
150
|
+
|
|
151
|
+
@declarations << Declaration.new(fqn: (nesting + [node.name.to_s]).join("::"), path: @path,
|
|
152
|
+
line: node.location.start_line, superclass: nil, includes: [].freeze)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# `include` / `prepend` / `extend` argument names written directly in the declaration body. Only the
|
|
156
|
+
# top level of the body is inspected: a mixin applied inside a conditional or a nested def is not a
|
|
157
|
+
# static ancestor edge.
|
|
158
|
+
def mixin_names(body)
|
|
159
|
+
body.child_nodes.filter_map do |stmt|
|
|
160
|
+
next unless stmt.is_a?(Prism::CallNode) && %i[include prepend extend].include?(stmt.name)
|
|
161
|
+
next if stmt.receiver
|
|
162
|
+
|
|
163
|
+
arg = stmt.arguments&.arguments&.first
|
|
164
|
+
arg && Source::ConstantPath.qualified_name_or_nil(arg)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
|
|
169
|
+
# `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
|
|
170
|
+
DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
|
|
171
|
+
private_constant :DYNAMIC_RESOLVERS
|
|
172
|
+
|
|
173
|
+
SUBJECT_SHAPES = {
|
|
174
|
+
Prism::StringNode => "a literal string",
|
|
175
|
+
Prism::SymbolNode => "a literal symbol",
|
|
176
|
+
Prism::InterpolatedStringNode => "an interpolated string"
|
|
177
|
+
}.freeze
|
|
178
|
+
private_constant :SUBJECT_SHAPES
|
|
179
|
+
|
|
180
|
+
# Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
|
|
181
|
+
# as a blanket namespace poison: a literal argument names the exact constant and is as good as a
|
|
182
|
+
# written reference, while an interpolated one can only bound the namespace it reaches into.
|
|
183
|
+
def record_dynamic_use(node)
|
|
184
|
+
return unless DYNAMIC_RESOLVERS.include?(node.name)
|
|
185
|
+
|
|
186
|
+
subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
|
|
187
|
+
return if subject.nil?
|
|
188
|
+
|
|
189
|
+
name, prefix = dynamic_target(subject)
|
|
190
|
+
# A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
|
|
191
|
+
# answer, and carrying it forward crashed the whole run downstream.
|
|
192
|
+
return if subject.is_a?(Prism::StringNode) && name.nil?
|
|
193
|
+
return if subject.is_a?(Prism::SymbolNode) && name.nil?
|
|
194
|
+
|
|
195
|
+
@dynamic_uses << DynamicUse.new(
|
|
196
|
+
name: name, prefix: prefix, site: site(node), path: @path, line: node.location.start_line,
|
|
197
|
+
reason: "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `[exact name, bounded namespace]` for a dynamic-resolution subject. A literal names its constant
|
|
202
|
+
# exactly; an interpolation can only bound the namespace its literal head names; anything else bounds
|
|
203
|
+
# nothing.
|
|
204
|
+
def dynamic_target(subject)
|
|
205
|
+
case subject
|
|
206
|
+
when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
|
|
207
|
+
when Prism::InterpolatedStringNode then [nil, literal_prefix(subject)]
|
|
208
|
+
else [nil, nil]
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The literal head of an interpolated name: `"Foo::Bar::#{k}"` bounds the reach to `Foo::Bar`. Returns
|
|
213
|
+
# nil when the interpolation starts the string, which bounds nothing.
|
|
214
|
+
def site(node)
|
|
215
|
+
"#{@path}:#{node.location.start_line}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def literal_prefix(node)
|
|
219
|
+
head = node.parts.first
|
|
220
|
+
return nil unless head.is_a?(Prism::StringNode)
|
|
221
|
+
|
|
222
|
+
literal = Scan.usable_name(head.unescaped)
|
|
223
|
+
return nil if literal.nil?
|
|
224
|
+
|
|
225
|
+
trimmed = literal.sub(/::\z/, "")
|
|
226
|
+
trimmed.empty? ? nil : trimmed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def record_reference(node, nesting)
|
|
230
|
+
as_written = Source::ConstantPath.qualified_name_or_nil(node)
|
|
231
|
+
return if as_written.nil?
|
|
232
|
+
|
|
233
|
+
@references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
|
|
234
|
+
from: nesting.empty? ? nil : nesting.join("::"),
|
|
235
|
+
role: @role, path: @path, line: node.location.start_line)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
|
|
5
|
+
require_relative "scan"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Analysis
|
|
9
|
+
module Reachability
|
|
10
|
+
# ADR-102 WD7 / issue #363 — the RBS half of the reference corpus.
|
|
11
|
+
#
|
|
12
|
+
# A constant named only from the project's own `sig/` is genuinely referenced, which is why signatures
|
|
13
|
+
# are read at all: the #345 probe reported such constants as false candidates until an RBS-side hook was
|
|
14
|
+
# added. But a signature file also DECLARES, and the two must not be confused.
|
|
15
|
+
#
|
|
16
|
+
# The first implementation scanned each file for constant-shaped tokens, which cannot tell the
|
|
17
|
+
# difference. With `rbs-inline`-generated signatures — where every project class has a mirror
|
|
18
|
+
# declaration under `sig/` — that made a large fraction of the project unconditionally reachable: on one
|
|
19
|
+
# application 48 of 101 roots came from `sig/`, hiding seven candidates and ten test-only rows. Over-supply
|
|
20
|
+
# is the worse direction (an under-supplied root leaves a row where a human can see it; an over-supplied
|
|
21
|
+
# one removes it where nobody will), so this parses instead.
|
|
22
|
+
#
|
|
23
|
+
# The rule: a declaration's own name is a DECLARATION and contributes nothing. Every type name appearing
|
|
24
|
+
# in a POSITION — a superclass, a mixin argument, a parameter or return type, a constant's type, a type
|
|
25
|
+
# alias body, a generic argument — is a reference and counts.
|
|
26
|
+
module SignatureScan
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param file [String] path to a `.rbs` file.
|
|
30
|
+
# @return [Array<Scan::Reference>] one file-level reference per distinct referenced name. Empty when the
|
|
31
|
+
# file cannot be read or parsed — a broken signature is the analyzer's business, not this scan's.
|
|
32
|
+
def call(file)
|
|
33
|
+
_, _, decls = ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: file, content: File.read(file)))
|
|
34
|
+
found = []
|
|
35
|
+
decls.each { |decl| walk(decl, [], found) }
|
|
36
|
+
found.uniq.map do |name, nesting|
|
|
37
|
+
Scan::Reference.new(as_written: name, nesting: nesting, from: nil, role: :config, path: file,
|
|
38
|
+
line: 1)
|
|
39
|
+
end
|
|
40
|
+
rescue ::RBS::BaseError, SystemCallError, ArgumentError
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Walks a declaration, threading the lexical nesting so a name written relative to an enclosing module
|
|
45
|
+
# resolves the way the graph's own candidate walk expects. Without this, `module A; class B < C; end`
|
|
46
|
+
# would contribute a bare `C` that resolves against nothing.
|
|
47
|
+
def walk(node, nesting, out)
|
|
48
|
+
case node
|
|
49
|
+
when ::RBS::AST::Declarations::Class then walk_class(node, nesting, out)
|
|
50
|
+
when ::RBS::AST::Declarations::Module then walk_module(node, nesting, out)
|
|
51
|
+
when ::RBS::AST::Declarations::Interface
|
|
52
|
+
descend(node, nesting + segments(node.name), out)
|
|
53
|
+
when ::RBS::AST::Members::Include, ::RBS::AST::Members::Extend, ::RBS::AST::Members::Prepend
|
|
54
|
+
record(node.name, nesting, out)
|
|
55
|
+
node.args&.each { |arg| collect_types(arg, nesting, out) }
|
|
56
|
+
when ::RBS::AST::Members::MethodDefinition
|
|
57
|
+
node.overloads.each { |overload| collect_types(overload.method_type, nesting, out) }
|
|
58
|
+
when *TYPED_NODES
|
|
59
|
+
collect_types(node.type, nesting, out)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Nodes whose whole contribution is the type they carry: a constant's or alias's right-hand side, and
|
|
64
|
+
# the attribute / variable families.
|
|
65
|
+
TYPED_NODES = [
|
|
66
|
+
::RBS::AST::Declarations::Constant, ::RBS::AST::Declarations::TypeAlias,
|
|
67
|
+
::RBS::AST::Members::AttrReader, ::RBS::AST::Members::AttrWriter,
|
|
68
|
+
::RBS::AST::Members::AttrAccessor, ::RBS::AST::Members::InstanceVariable,
|
|
69
|
+
::RBS::AST::Members::ClassInstanceVariable, ::RBS::AST::Members::ClassVariable
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
def walk_class(node, nesting, out)
|
|
73
|
+
record(node.super_class&.name, nesting, out)
|
|
74
|
+
node.super_class&.args&.each { |arg| collect_types(arg, nesting, out) }
|
|
75
|
+
descend(node, nesting + segments(node.name), out)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def walk_module(node, nesting, out)
|
|
79
|
+
node.self_types&.each { |self_type| record(self_type.name, nesting, out) }
|
|
80
|
+
descend(node, nesting + segments(node.name), out)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def descend(node, nesting, out)
|
|
84
|
+
node.members&.each { |member| walk(member, nesting, out) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every `RBS::TypeName` reachable from a type, found reflectively. RBS's type zoo is wide (unions,
|
|
88
|
+
# intersections, tuples, records, optionals, procs, generics) and grows between releases; enumerating
|
|
89
|
+
# the classes would silently drop references the day a new one lands, and dropping a reference here
|
|
90
|
+
# manufactures a false candidate.
|
|
91
|
+
def collect_types(type, nesting, out)
|
|
92
|
+
return if type.nil?
|
|
93
|
+
|
|
94
|
+
record(type.name, nesting, out) if type.respond_to?(:name) && type.name.is_a?(::RBS::TypeName)
|
|
95
|
+
type.instance_variables.each do |ivar|
|
|
96
|
+
value = type.instance_variable_get(ivar)
|
|
97
|
+
case value
|
|
98
|
+
when ::RBS::TypeName then record(value, nesting, out)
|
|
99
|
+
when Array then value.each { |v| collect_types(v, nesting, out) }
|
|
100
|
+
when Hash then value.each_value { |v| collect_types(v, nesting, out) }
|
|
101
|
+
else collect_types(value, nesting, out) if value.respond_to?(:instance_variables)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Records the name AS WRITTEN together with the nesting it was written under, and lets
|
|
107
|
+
# {Graph#resolve} do the lexical walk — the same walk it does for a name read out of Ruby source.
|
|
108
|
+
#
|
|
109
|
+
# An earlier version hand-built the qualified form instead (`"#{nesting}::#{bare}"`) on the reasoning
|
|
110
|
+
# that over-approximating which of absolute / relative / top-level a name is could only fail to remove
|
|
111
|
+
# a candidate. That was wrong, and it resurrected the very defect #363 fixed. `include Alba::Resource`
|
|
112
|
+
# inside `class SignageResource` produced `SignageResource::Alba::Resource`; the graph resolves a
|
|
113
|
+
# reference to a member as a reference to its owner, so it peeled that to `SignageResource::Alba` and
|
|
114
|
+
# then to `SignageResource` — a declaration. The reference is file-level, so the class rooted ITSELF,
|
|
115
|
+
# and three genuinely dead classes stayed out of the report on the project where this was found.
|
|
116
|
+
#
|
|
117
|
+
# Passing the real nesting cannot do that: the walk tries the qualified candidate, misses, and the peel
|
|
118
|
+
# then applies to the written name alone.
|
|
119
|
+
def record(type_name, nesting, out)
|
|
120
|
+
return if type_name.nil?
|
|
121
|
+
|
|
122
|
+
bare = type_name.to_s.delete_prefix("::")
|
|
123
|
+
return if bare.empty?
|
|
124
|
+
|
|
125
|
+
out << [bare, nesting.dup.freeze]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def segments(type_name)
|
|
129
|
+
type_name.to_s.delete_prefix("::").split("::")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -660,6 +660,8 @@ module Rigor
|
|
|
660
660
|
"The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
|
|
661
661
|
"The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
|
|
662
662
|
"be enumerated statically and under-warning is the FP-safe direction.",
|
|
663
|
+
"The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
|
|
664
|
+
"`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
|
|
663
665
|
"The comment merely mentions the marker followed by non-token text (documentation prose " \
|
|
664
666
|
"like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
|
|
665
667
|
],
|
|
@@ -684,6 +686,7 @@ module Rigor
|
|
|
684
686
|
does_not_fire_when: [
|
|
685
687
|
"At least one token follows the marker (each token is then checked by " \
|
|
686
688
|
"`suppression.unknown-rule` instead).",
|
|
689
|
+
"The marker does not open the comment (documentation prose quoting the syntax).",
|
|
687
690
|
"Non-token text follows the marker (documentation prose mentioning the syntax)."
|
|
688
691
|
],
|
|
689
692
|
suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
|
|
@@ -725,12 +728,170 @@ module Rigor
|
|
|
725
728
|
since: "0.3.0"
|
|
726
729
|
),
|
|
727
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
|
+
|
|
728
889
|
CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
|
|
729
890
|
id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
|
|
730
891
|
summary: "A comment uses a suppression marker Rigor does not recognise " \
|
|
731
892
|
"(`rigor:disable-next-line`, `rigor:enable`, ...).",
|
|
732
893
|
fires_when: [
|
|
733
|
-
"A comment
|
|
894
|
+
"A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
|
|
734
895
|
"`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
|
|
735
896
|
"<rule>` — followed by nothing or a rule-list-shaped remainder.",
|
|
736
897
|
"Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
|
|
@@ -740,6 +901,7 @@ module Rigor
|
|
|
740
901
|
does_not_fire_when: [
|
|
741
902
|
"The marker is one of the two recognised forms (their tokens are then checked by " \
|
|
742
903
|
"`suppression.unknown-rule` / `suppression.empty` instead).",
|
|
904
|
+
"The marker does not open the comment (documentation prose quoting the spelling).",
|
|
743
905
|
"Non-token text follows the marker (documentation prose mentioning the spelling)."
|
|
744
906
|
],
|
|
745
907
|
suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
|
|
@@ -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
|