rigortype 0.3.3 → 0.3.5
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 +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -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/rule_ids.rb +28 -2
- 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 +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -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 +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -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 +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -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 +139 -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 +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -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 +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -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 +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -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/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -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 +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- 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 +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- 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-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/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/node_children"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Which of a method body's locals the frame **owns** — freshly allocated here and never let out
|
|
10
|
+
# (ADR-103 WD4; the proof obligations are `control-flow-analysis.md` § Proof obligations).
|
|
11
|
+
#
|
|
12
|
+
# Ruby has no by-ref parameters, so `mutate.local` cannot mean "a write into an out-parameter" as it
|
|
13
|
+
# does in PHP. It means the mutated receiver is invisible to the caller, and that is an ownership
|
|
14
|
+
# question: a local whose every assignment allocates (`[]`, `{}`, `""`, `.new`, `.dup`) and which never
|
|
15
|
+
# escapes the body is one no caller can observe being mutated.
|
|
16
|
+
#
|
|
17
|
+
# The analysis is deliberately **flow-insensitive and whole-body**: a local that escapes anywhere
|
|
18
|
+
# disqualifies, even if the escape happens after the mutation. That is strictly more conservative than
|
|
19
|
+
# the "escaped before the mutating call" reading, which is the direction false positives are budgeted
|
|
20
|
+
# in ([ADR-5](../adr/5-robustness-principle.md)) — an unproven local mutation becomes an
|
|
21
|
+
# `unknown-ownership` taint, never a proven `mutate` label.
|
|
22
|
+
#
|
|
23
|
+
# This is the tracer slice's approximation, not the eventual answer. `ClosureEscapeAnalyzer` answers a
|
|
24
|
+
# different question (fact retention, not "does the code contain") and is deliberately left alone.
|
|
25
|
+
module LocalOwnership
|
|
26
|
+
# Assignment right-hand sides that witness a fresh allocation. `.new` and `.dup` / `.clone` follow
|
|
27
|
+
# [ADR-76](../adr/76-effect-modeling-freeze-dup-shape-preservation.md)'s reading of `dup` as the
|
|
28
|
+
# allocation witness.
|
|
29
|
+
ALLOCATING_SELECTORS = %i[new dup clone].to_set.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The set of frame-owned local names in `body`, given the method's parameter names (a parameter is
|
|
34
|
+
# never frame-owned — the caller holds the same object, so mutating it is `mutate.instance`).
|
|
35
|
+
def owned(body, parameter_names)
|
|
36
|
+
return Set.new if body.nil?
|
|
37
|
+
|
|
38
|
+
assignments = {}
|
|
39
|
+
escaped = Set.new
|
|
40
|
+
collect(body, assignments, escaped)
|
|
41
|
+
escaped.merge(trailing_reads(body))
|
|
42
|
+
assignments.filter_map do |name, values|
|
|
43
|
+
next if escaped.include?(name) || parameter_names.include?(name)
|
|
44
|
+
|
|
45
|
+
name if values.all? { |value| allocation?(value) }
|
|
46
|
+
end.to_set
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether `node` is an expression that allocates a fresh object this frame is the sole holder of.
|
|
50
|
+
def allocation?(node)
|
|
51
|
+
case node
|
|
52
|
+
when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode,
|
|
53
|
+
Prism::LambdaNode
|
|
54
|
+
true
|
|
55
|
+
when Prism::CallNode
|
|
56
|
+
ALLOCATING_SELECTORS.include?(node.name) || unary_plus_string?(node)
|
|
57
|
+
else
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `+""` — the frozen-string-literal era's spelling of "a fresh mutable String".
|
|
63
|
+
def unary_plus_string?(node)
|
|
64
|
+
node.name == :+@ && node.receiver.is_a?(Prism::StringNode)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect(node, assignments, escaped)
|
|
68
|
+
return unless node.is_a?(Prism::Node)
|
|
69
|
+
|
|
70
|
+
record_assignment(node, assignments, escaped)
|
|
71
|
+
record_escapes(node, escaped)
|
|
72
|
+
node.rigor_each_child { |child| collect(child, assignments, escaped) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_assignment(node, assignments, escaped)
|
|
76
|
+
case node
|
|
77
|
+
when Prism::LocalVariableWriteNode
|
|
78
|
+
(assignments[node.name.to_s] ||= []) << node.value
|
|
79
|
+
# `y = x` hands the same object to a second name; neither can be proven frame-private cheaply.
|
|
80
|
+
escaped << node.value.name.to_s if node.value.is_a?(Prism::LocalVariableReadNode)
|
|
81
|
+
when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
|
|
82
|
+
Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
|
|
83
|
+
# Not an allocation, and a multi-assign target's value is not statically one either: record a
|
|
84
|
+
# nil right-hand side so the all-allocations test fails.
|
|
85
|
+
(assignments[node.name.to_s] ||= []) << nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An escape is any position from which a caller could later reach the object: a call argument (the
|
|
90
|
+
# callee may store it), the right-hand side of a write to state that outlives the frame, an element
|
|
91
|
+
# of a constructed collection, or an explicit `return`.
|
|
92
|
+
def record_escapes(node, escaped)
|
|
93
|
+
case node
|
|
94
|
+
when Prism::CallNode
|
|
95
|
+
node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
|
|
96
|
+
note_read(node.block.expression, escaped) if node.block.is_a?(Prism::BlockArgumentNode)
|
|
97
|
+
when Prism::ReturnNode
|
|
98
|
+
node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
|
|
99
|
+
when Prism::ArrayNode
|
|
100
|
+
node.elements.each { |element| note_read(element, escaped) }
|
|
101
|
+
else
|
|
102
|
+
note_read(stored_value(node), escaped)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The value half of a write into state that outlives the frame, or of a hash entry. nil for every
|
|
107
|
+
# other node, which {note_read} ignores.
|
|
108
|
+
def stored_value(node)
|
|
109
|
+
case node
|
|
110
|
+
when Prism::AssocNode, Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
|
|
111
|
+
Prism::GlobalVariableWriteNode, Prism::ConstantWriteNode
|
|
112
|
+
node.value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def note_read(node, escaped)
|
|
117
|
+
escaped << node.name.to_s if node.is_a?(Prism::LocalVariableReadNode)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A body whose value is a bare local read hands that local to the caller. Only the tail matters —
|
|
121
|
+
# every other position is covered by {record_escapes}.
|
|
122
|
+
def trailing_reads(body)
|
|
123
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
124
|
+
last = statements.last
|
|
125
|
+
last.is_a?(Prism::LocalVariableReadNode) ? [last.name.to_s] : []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method :collect, :record_assignment, :record_escapes, :stored_value, :note_read,
|
|
129
|
+
:trailing_reads
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# The spelling of an effect unit's key, in one place (ADR-103 WD14).
|
|
6
|
+
#
|
|
7
|
+
# `Owner#instance_method`, `Owner.singleton_method`, `<toplevel>#bare_def`. The separator is the FIRST
|
|
8
|
+
# `#` or `.` in the string, which is what makes `Net::HTTP.get` split at the dot rather than inside the
|
|
9
|
+
# namespace — a namespace carries `::`, never a bare dot, and a selector carries neither.
|
|
10
|
+
#
|
|
11
|
+
# This module exists because the split is now written on both sides of a contract: the scanner spells a
|
|
12
|
+
# key, and `effects.attribution:` in `.rigor.yml` names one. A key the loader accepts and the scanner
|
|
13
|
+
# would never produce is a table that silently matches nothing.
|
|
14
|
+
module MethodKey
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @return [Array(String, String, String), nil] `[owner, separator, selector]`, or nil when `key` is
|
|
18
|
+
# not a method key at all.
|
|
19
|
+
def split(key)
|
|
20
|
+
text = key.to_s
|
|
21
|
+
index = text.index("#") || text.index(".")
|
|
22
|
+
return nil if index.nil? || index.zero? || index == text.length - 1
|
|
23
|
+
|
|
24
|
+
[text[0, index], text[index], text[(index + 1)..]]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid?(key)
|
|
28
|
+
parts = split(key)
|
|
29
|
+
return false if parts.nil?
|
|
30
|
+
|
|
31
|
+
parts.none? { |part| part.match?(/\s/) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The owner half, or nil. What `keys_by_class`-shaped groupings ask for.
|
|
35
|
+
def owner(key)
|
|
36
|
+
split(key)&.first
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../inference/mutation_widening"
|
|
6
|
+
require_relative "label_set"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# Decides whether a call mutates its receiver, and — when it does — which `mutate.*` label that earns
|
|
11
|
+
# (ADR-103 WD4 / WD14).
|
|
12
|
+
#
|
|
13
|
+
# Two independent questions, both answered conservatively:
|
|
14
|
+
#
|
|
15
|
+
# 1. **Is this a mutation?** Only when the selector says so beyond doubt. `[]=` and an attribute writer
|
|
16
|
+
# are writes on every receiver; `<<` and the bang family are claimed only when the typer named the
|
|
17
|
+
# receiver's class, because `n << 2` is a bit shift and `io << "x"` is output. A wrong label in the
|
|
18
|
+
# proven lane is worse than a missing one — the proven lane is the one a verdict may read (ADR-5).
|
|
19
|
+
# 2. **Who owns the receiver?** `self` and its ivars are `mutate.self` (`mutate.static` in singleton
|
|
20
|
+
# context), a class variable is `mutate.static`, a parameter is `mutate.instance`, a frame-owned
|
|
21
|
+
# local is `mutate.local`. **Anything else answers nil**, and the caller records an
|
|
22
|
+
# `unknown-ownership` taint rather than a proven bare `mutate`: Ruby's ownership is a dataflow
|
|
23
|
+
# question, and a proven parent label on a fresh-but-unproven receiver would put findings on correct
|
|
24
|
+
# code (WD14).
|
|
25
|
+
class MutationClassifier
|
|
26
|
+
# The only selectors a mutation may be claimed from without knowing the receiver's class.
|
|
27
|
+
UNIVERSAL_MUTATORS = %i[[]=].to_set.freeze
|
|
28
|
+
|
|
29
|
+
# `String`'s receiver-mutating surface. `Array` / `Hash` reuse the hand-audited sets the widening
|
|
30
|
+
# rules already maintain, cited rather than re-derived (ADR-103 WD3).
|
|
31
|
+
STRING_MUTATORS = %i[
|
|
32
|
+
<< concat replace insert prepend clear
|
|
33
|
+
upcase! downcase! capitalize! swapcase! reverse!
|
|
34
|
+
strip! lstrip! rstrip! chomp! chop! squeeze! succ! next!
|
|
35
|
+
sub! gsub! tr! tr_s! delete! slice! []=
|
|
36
|
+
].to_set.freeze
|
|
37
|
+
|
|
38
|
+
# `foo=`, and deliberately not `==` / `<=` / `!=` / `===`.
|
|
39
|
+
ATTRIBUTE_WRITER = /\A[a-z_][A-Za-z0-9_]*=\z/
|
|
40
|
+
|
|
41
|
+
LABELS = {
|
|
42
|
+
self_state: LabelSet.new(["mutate.self"]),
|
|
43
|
+
static: LabelSet.new(["mutate.static"]),
|
|
44
|
+
instance: LabelSet.new(["mutate.instance"]),
|
|
45
|
+
local: LabelSet.new(["mutate.local"])
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
def initialize(singleton:, parameters:, owned_locals:)
|
|
49
|
+
@singleton = singleton
|
|
50
|
+
@parameters = parameters
|
|
51
|
+
@owned_locals = owned_locals
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether `node` mutates its receiver. `receiver_class` is the class the typer projected the
|
|
55
|
+
# receiver's type to, or nil when it projected to none.
|
|
56
|
+
def mutating?(node, receiver_class)
|
|
57
|
+
name = node.name
|
|
58
|
+
return true if UNIVERSAL_MUTATORS.include?(name) || ATTRIBUTE_WRITER.match?(name.to_s)
|
|
59
|
+
|
|
60
|
+
case receiver_class
|
|
61
|
+
when "Array" then Inference::MutationWidening::ARRAY_MUTATORS.include?(name)
|
|
62
|
+
when "Hash" then Inference::MutationWidening::HASH_MUTATORS.include?(name)
|
|
63
|
+
when "String" then STRING_MUTATORS.include?(name)
|
|
64
|
+
else false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The label a mutation of `receiver` earns, or nil when ownership is not provable.
|
|
69
|
+
def label_for(receiver)
|
|
70
|
+
LABELS[ownership(receiver)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def ownership(receiver)
|
|
76
|
+
case receiver
|
|
77
|
+
when nil, Prism::SelfNode, Prism::InstanceVariableReadNode
|
|
78
|
+
@singleton ? :static : :self_state
|
|
79
|
+
when Prism::ClassVariableReadNode then :static
|
|
80
|
+
when Prism::LocalVariableReadNode then local_ownership(receiver.name.to_s)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def local_ownership(name)
|
|
85
|
+
return :instance if @parameters.include?(name)
|
|
86
|
+
return :local if @owned_locals.include?(name)
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -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
|