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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "label_set"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Argument-dependent narrowing for catalogue rows (ADR-103 WD3; the row's `narrow:` names one of
|
|
10
|
+
# these handlers, and `data/effects/core.yml` says why each row wants one).
|
|
11
|
+
#
|
|
12
|
+
# A handler reads **the call's own argument literals and nothing else**. There is no dataflow here
|
|
13
|
+
# on purpose: the effect scan is observational (it never asks the typer for anything it did not
|
|
14
|
+
# already decide), and a narrowing that depended on inference would make the catalogue's answer a
|
|
15
|
+
# function of analysis quality rather than of the source in front of it.
|
|
16
|
+
#
|
|
17
|
+
# Every handler is total and answers an **upper bound**: when the literal does not settle the
|
|
18
|
+
# question it returns the row's parent label, never a guess. `open(path)` with a computed argument
|
|
19
|
+
# is `io` — file, pipe or URI — and that is the honest reading of the classic pipe-injection shape.
|
|
20
|
+
#
|
|
21
|
+
# Handlers are module functions dispatched by a `case`, deliberately not a Hash of lambdas: the
|
|
22
|
+
# catalogue is loaded once per process and inherited across the fork pool, and a table of procs is
|
|
23
|
+
# neither Marshal-clean nor shareable.
|
|
24
|
+
module Narrowing
|
|
25
|
+
IO_ANY = LabelSet.new(["io"]).freeze
|
|
26
|
+
FS = LabelSet.new(["io.fs"]).freeze
|
|
27
|
+
FS_READ = LabelSet.new(["io.fs.read"]).freeze
|
|
28
|
+
FS_WRITE = LabelSet.new(["io.fs.write"]).freeze
|
|
29
|
+
FS_READ_WRITE = LabelSet.new(["io.fs.read", "io.fs.write"]).freeze
|
|
30
|
+
PROCESS = LabelSet.new(["io.process"]).freeze
|
|
31
|
+
HTTP = LabelSet.new(["io.net.http"]).freeze
|
|
32
|
+
TIME = LabelSet.new(["nondet.time"]).freeze
|
|
33
|
+
DB = LabelSet.new(["io.db"]).freeze
|
|
34
|
+
DB_READ = LabelSet.new(["io.db.read"]).freeze
|
|
35
|
+
DB_WRITE = LabelSet.new(["io.db.write"]).freeze
|
|
36
|
+
DB_TRANSACTION = LabelSet.new(["io.db.transaction"]).freeze
|
|
37
|
+
RANDOM = LabelSet.new(["nondet.random"]).freeze
|
|
38
|
+
NONE = LabelSet::EMPTY
|
|
39
|
+
|
|
40
|
+
# Ruby's file modes, minus the encoding suffix (`"r:UTF-8"`) and the `b` / `t` flags. `"r"` is the
|
|
41
|
+
# only pure read; every `+` form reads and writes.
|
|
42
|
+
WRITE_MODES = %w[w a w+ a+ r+].freeze
|
|
43
|
+
private_constant :WRITE_MODES
|
|
44
|
+
|
|
45
|
+
# SQL verbs, by what they do to the database. `WITH` heads a CTE whose tail may be either, so it
|
|
46
|
+
# deliberately does not narrow. Everything not listed answers `io.db`.
|
|
47
|
+
SQL_READ_VERBS = %w[SELECT SHOW EXPLAIN DESCRIBE DESC PRAGMA].freeze
|
|
48
|
+
SQL_WRITE_VERBS = %w[
|
|
49
|
+
INSERT UPDATE DELETE REPLACE UPSERT MERGE TRUNCATE
|
|
50
|
+
CREATE ALTER DROP RENAME COMMENT GRANT REVOKE REINDEX VACUUM ANALYZE COPY
|
|
51
|
+
].freeze
|
|
52
|
+
SQL_TRANSACTION_VERBS = %w[BEGIN START COMMIT ROLLBACK SAVEPOINT RELEASE SET LOCK].freeze
|
|
53
|
+
private_constant :SQL_READ_VERBS, :SQL_WRITE_VERBS, :SQL_TRANSACTION_VERBS
|
|
54
|
+
|
|
55
|
+
HANDLERS = %w[kernel_open file_open pathname_open time_new random_new uri_open sql_verb].freeze
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
def known?(name)
|
|
60
|
+
HANDLERS.include?(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The labels `name`'s handler reads off `node`. An unknown handler name answers nil, which the
|
|
64
|
+
# catalogue loader rejects at load time rather than at call time.
|
|
65
|
+
def apply(name, node)
|
|
66
|
+
case name
|
|
67
|
+
when "kernel_open" then kernel_open(node)
|
|
68
|
+
when "file_open" then file_open(node)
|
|
69
|
+
when "pathname_open" then pathname_open(node)
|
|
70
|
+
when "time_new" then time_new(node)
|
|
71
|
+
when "random_new" then random_new(node)
|
|
72
|
+
when "uri_open" then uri_open(node)
|
|
73
|
+
when "sql_verb" then sql_verb(node)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `connection.execute(sql)` / `exec_query` / `select_all` — the SQL string's leading verb settles the
|
|
78
|
+
# direction, which is the argument-dependent narrowing of the design note § 4 applied to SQL. It is
|
|
79
|
+
# the one place a raw-SQL escape hatch can still be read precisely, and the shape that matters most —
|
|
80
|
+
# `execute("UPDATE …")` inside a method whose envelope says `io.db.read` — is exactly the one a
|
|
81
|
+
# literal answers.
|
|
82
|
+
#
|
|
83
|
+
# Interpolation is fine as long as the VERB is written out: `execute("SELECT * FROM #{table}")`
|
|
84
|
+
# narrows, because {literal_prefix} keeps the leading literal run. A fully computed string does not,
|
|
85
|
+
# and answers the parent `io.db`.
|
|
86
|
+
def sql_verb(node)
|
|
87
|
+
head = literal_prefix(argument(node, 0))
|
|
88
|
+
return DB if head.nil?
|
|
89
|
+
|
|
90
|
+
verb = head[/[A-Za-z]+/]&.upcase
|
|
91
|
+
return DB if verb.nil?
|
|
92
|
+
return DB_READ if SQL_READ_VERBS.include?(verb)
|
|
93
|
+
return DB_WRITE if SQL_WRITE_VERBS.include?(verb)
|
|
94
|
+
return DB_TRANSACTION if SQL_TRANSACTION_VERBS.include?(verb)
|
|
95
|
+
|
|
96
|
+
DB
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `Kernel#open` — a path, a `|command` pipe, or (with open-uri loaded) a URI. A literal leading
|
|
100
|
+
# `|` is the pipe form; anything else literal is a path, whose direction the mode literal decides.
|
|
101
|
+
def kernel_open(node)
|
|
102
|
+
target = literal_prefix(argument(node, 0))
|
|
103
|
+
return IO_ANY if target.nil?
|
|
104
|
+
return PROCESS if target.start_with?("|")
|
|
105
|
+
|
|
106
|
+
mode_labels(node, 1)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `File.open(path, mode)` — the mode literal decides the direction. An ABSENT mode is not an
|
|
110
|
+
# unknown one: Ruby's default is `"r"`, so it reads. A mode the call computes — or an integer flag
|
|
111
|
+
# such as `File::RDWR`, which the scan deliberately does not resolve — is genuinely unknown and
|
|
112
|
+
# answers the subsystem parent.
|
|
113
|
+
def file_open(node)
|
|
114
|
+
mode_labels(node, 1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `Pathname#open(mode)` — the same reading one argument to the left, because the receiver is the
|
|
118
|
+
# path.
|
|
119
|
+
def pathname_open(node)
|
|
120
|
+
mode_labels(node, 0)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# `Time.new` — with no positional arguments it is `Time.now`; with any it constructs from them and
|
|
124
|
+
# consults no clock. Keyword arguments (`in:`) do not count: `Time.new(in: "+09:00")` is still now.
|
|
125
|
+
def time_new(node)
|
|
126
|
+
positional_count(node).zero? ? TIME : NONE
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# `Random.new` — a seed argument makes the generator reproducible from the source; without one it
|
|
130
|
+
# draws the seed from platform entropy.
|
|
131
|
+
def random_new(node)
|
|
132
|
+
positional_count(node).zero? ? RANDOM : NONE
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `URI.open` / `OpenURI.open_uri` — the scheme literal decides the subsystem. A bare path (no
|
|
136
|
+
# `scheme://`) is open-uri's filesystem fallback; a scheme nobody rowed answers the parent.
|
|
137
|
+
def uri_open(node)
|
|
138
|
+
target = literal_prefix(argument(node, 0))
|
|
139
|
+
return IO_ANY if target.nil?
|
|
140
|
+
return HTTP if target.start_with?("http://", "https://")
|
|
141
|
+
return FS_READ if target.start_with?("file://")
|
|
142
|
+
return IO_ANY if target.include?("://")
|
|
143
|
+
|
|
144
|
+
FS_READ
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The direction a mode argument proves. Three states, and the middle one is the reason this is not
|
|
148
|
+
# a two-way test: **absent** is Ruby's `"r"` default, **present but unreadable** is genuinely
|
|
149
|
+
# unknown and answers the subsystem parent, and **a literal** narrows.
|
|
150
|
+
def mode_labels(node, index)
|
|
151
|
+
mode = argument(node, index) || keyword_argument(node, "mode")
|
|
152
|
+
return FS_READ if mode.nil?
|
|
153
|
+
|
|
154
|
+
canonical = string_literal(mode)&.[](/\A[rwa]\+?/)
|
|
155
|
+
return FS if canonical.nil?
|
|
156
|
+
return FS_READ unless WRITE_MODES.include?(canonical)
|
|
157
|
+
|
|
158
|
+
canonical.end_with?("+") ? FS_READ_WRITE : FS_WRITE
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def argument(node, index)
|
|
162
|
+
positional(node)[index]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def positional_count(node)
|
|
166
|
+
positional(node).length
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def positional(node)
|
|
170
|
+
node.arguments&.arguments&.grep_v(Prism::KeywordHashNode) || []
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def keyword_argument(node, name)
|
|
174
|
+
hash = node.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
|
|
175
|
+
pair = hash&.elements&.find do |element|
|
|
176
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
177
|
+
element.key.unescaped == name
|
|
178
|
+
end
|
|
179
|
+
pair&.value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def string_literal(node)
|
|
183
|
+
node.unescaped if node.is_a?(Prism::StringNode)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The literal head of a string argument: the whole thing for a plain literal, and the leading
|
|
187
|
+
# literal run for an interpolated one. `open("|#{cmd}")` and `URI.open("https://#{host}/x")` are
|
|
188
|
+
# the shapes that matter — the part that decides the subsystem is written out even when the rest
|
|
189
|
+
# is computed.
|
|
190
|
+
def literal_prefix(node)
|
|
191
|
+
return node.unescaped if node.is_a?(Prism::StringNode)
|
|
192
|
+
return nil unless node.is_a?(Prism::InterpolatedStringNode)
|
|
193
|
+
|
|
194
|
+
head = node.parts.first
|
|
195
|
+
head.is_a?(Prism::StringNode) ? head.unescaped : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private_class_method :sql_verb, :mode_labels, :argument, :positional_count, :positional,
|
|
199
|
+
:keyword_argument, :string_literal, :literal_prefix
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# Where one bundle of labels in a summary came from (ADR-103 WD14).
|
|
6
|
+
#
|
|
7
|
+
# An origin is the pair of **what** coloured the labels and **which source** did the colouring, and it
|
|
8
|
+
# is deliberately **line-free**: two `puts` calls in one method share one origin, so a summary is stable
|
|
9
|
+
# under a line move and a snapshot diff shows a change of behaviour rather than a change of formatting.
|
|
10
|
+
# Call sites are kept separately, per run, for the report only.
|
|
11
|
+
#
|
|
12
|
+
# `name` is the callee key (`Kernel#puts`, `Time.now`) for a catalogued row, and the construct's name
|
|
13
|
+
# (`xstring`, `gvar-write`) for a language construct. `source` says which of the two it is, because the
|
|
14
|
+
# two namespaces overlap in principle and a later slice discharges policy per origin.
|
|
15
|
+
class Origin < Data.define(:name, :source)
|
|
16
|
+
# A Ruby language construct — backticks, a `$gvar` write, an `@ivar` write (§ 5.1 of the design note).
|
|
17
|
+
CONSTRUCT = :construct
|
|
18
|
+
# A row of the built-in effect catalogue ({Catalog}; `data/effects/core.yml` from #380).
|
|
19
|
+
CATALOGUE = :catalogue
|
|
20
|
+
# A row of the project's own `effects.attribution:` table — a claim about code Rigor did not
|
|
21
|
+
# analyse. Its labels land in the summary's DECLARED lane and never in the proven one, and the site
|
|
22
|
+
# keeps its `plugin-attribution` taint (ADR-103 WD6: "declared this, and possibly more").
|
|
23
|
+
ATTRIBUTION = :attribution
|
|
24
|
+
# An {Envelope} the callee's own declaration carries — a project annotation, an
|
|
25
|
+
# `effects.envelopes:` convention, or an accepted signature's `%a{…}` (#386). Its labels land in
|
|
26
|
+
# the DECLARED lane, like an attribution's, but unlike an attribution the bound is *checked* (or
|
|
27
|
+
# trusted at the type tier), so the site is exhaustive rather than tainted: ADR-103 WD6's
|
|
28
|
+
# discharging strata. `name` is the callee key the bound was resolved for.
|
|
29
|
+
ENVELOPE = :envelope
|
|
30
|
+
# A row of a loaded plugin's `effect_attributions:` — what the plugin that models a framework says a
|
|
31
|
+
# call into it does (#387; ADR-103 WD6 / WD10). Its labels land in the DECLARED lane, like an
|
|
32
|
+
# attribution's and an envelope's, and which of those two it behaves like is decided by the plugin's
|
|
33
|
+
# standing: a **first-party bundled** plugin's row discharges, so the site is exhaustive, exactly as
|
|
34
|
+
# an accepted signature's `%a{…}` is; a third-party plugin's does not, and the site keeps its
|
|
35
|
+
# `plugin-attribution` taint. One origin source for both, because the label came from the same place
|
|
36
|
+
# either way and `explain` should say so.
|
|
37
|
+
PLUGIN = :plugin
|
|
38
|
+
|
|
39
|
+
def self.construct(name)
|
|
40
|
+
new(name: name.to_s, source: CONSTRUCT)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.catalogue(name)
|
|
44
|
+
new(name: name.to_s, source: CATALOGUE)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.attribution(name)
|
|
48
|
+
new(name: name.to_s, source: ATTRIBUTION)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.envelope(name)
|
|
52
|
+
new(name: name.to_s, source: ENVELOPE)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.plugin(name)
|
|
56
|
+
new(name: name.to_s, source: PLUGIN)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The rendering the report and the JSON payload key on. Sorted lexicographically, so `catalogue:`
|
|
60
|
+
# rows group before `construct:` ones and the output is deterministic.
|
|
61
|
+
def to_s
|
|
62
|
+
"#{source}:#{name}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effect_table"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# "Why does this entry point reach `io.net.http`?" — the shortest edge path from a method to the
|
|
8
|
+
# origin that introduced a label (ADR-103 WD7; design note § 9.4, "the review feature that pays for
|
|
9
|
+
# the fixpoint").
|
|
10
|
+
#
|
|
11
|
+
# OrdersController#create → OrderService#place → PaymentGateway#charge → Net::HTTP.get [io.net.http]
|
|
12
|
+
#
|
|
13
|
+
# A breadth-first walk over the {EffectTable}'s resolved edges, so the answer is a *shortest* path
|
|
14
|
+
# rather than whichever one a depth-first walk stumbled into first — a reviewer wants the tightest
|
|
15
|
+
# explanation available. Ties break on sorted key order, so two runs explain a change identically.
|
|
16
|
+
#
|
|
17
|
+
# The path ends at the **origin**, not at the last method: the catalogue row or language construct
|
|
18
|
+
# that actually coloured the label is what the reviewer is looking for, and the method that owns it
|
|
19
|
+
# is the hop before.
|
|
20
|
+
module PathFinder
|
|
21
|
+
# One explanation.
|
|
22
|
+
#
|
|
23
|
+
# `chain` are the project method keys walked, entry point first; `origin` is the callee key or
|
|
24
|
+
# construct name that introduced the label, or nil when no method on the path proves it directly
|
|
25
|
+
# (which happens only when the table is inconsistent, and is rendered as the method chain alone).
|
|
26
|
+
class Path < Data.define(:symbol, :label, :chain, :origin, :origin_source)
|
|
27
|
+
# The rendering the text form prints and the JSON payload carries as `path`.
|
|
28
|
+
def to_a
|
|
29
|
+
origin.nil? ? chain : chain + [origin]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
"#{to_a.join(' → ')} [#{label}]"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# The shortest path from `symbol` to a direct origin of `label`, or nil when the table proves the
|
|
40
|
+
# label nowhere reachable from it.
|
|
41
|
+
def shortest(table, symbol:, label:)
|
|
42
|
+
entry = table[symbol]
|
|
43
|
+
return nil if entry.nil?
|
|
44
|
+
|
|
45
|
+
walk(table, symbol, label)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Every label of `symbol`'s transitive summary, explained. Sorted by label, so the output is stable.
|
|
49
|
+
def explain_all(table, symbol:)
|
|
50
|
+
entry = table[symbol]
|
|
51
|
+
return [] if entry.nil?
|
|
52
|
+
|
|
53
|
+
entry.proven.to_a.filter_map { |label| shortest(table, symbol: symbol, label: label) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def walk(table, start, label)
|
|
57
|
+
queue = [[start]]
|
|
58
|
+
seen = { start => true }
|
|
59
|
+
until queue.empty?
|
|
60
|
+
trail = queue.shift
|
|
61
|
+
entry = table[trail.last]
|
|
62
|
+
next if entry.nil?
|
|
63
|
+
|
|
64
|
+
origin = direct_origin(entry, label)
|
|
65
|
+
if origin
|
|
66
|
+
return Path.new(symbol: start, label: label, chain: trail.freeze,
|
|
67
|
+
origin: origin.name, origin_source: origin.source)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
entry.edges.each do |callee|
|
|
71
|
+
next unless seen[callee].nil?
|
|
72
|
+
|
|
73
|
+
seen[callee] = true
|
|
74
|
+
queue << (trail + [callee])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Which origin of this method's own summary carries the label. Sorted by rendering, so a method
|
|
81
|
+
# coloured by two rows explains through the same one every time.
|
|
82
|
+
def direct_origin(entry, label)
|
|
83
|
+
entry.direct.bundles.find { |_origin, labels| labels.include?(label) }&.first
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private_class_method :walk, :direct_origin
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|