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,335 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "entry_points"
|
|
6
|
+
require_relative "label_set"
|
|
7
|
+
require_relative "registry"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Effects
|
|
11
|
+
# The loaded plugins' effect contributions, compiled once per process into the tables the scan and the
|
|
12
|
+
# snapshot read (ADR-103 WD2 / WD6 / WD10 / WD14; #387).
|
|
13
|
+
#
|
|
14
|
+
# A plugin's manifest states its contribution declaratively — `effect_labels:`, `effect_attributions:`,
|
|
15
|
+
# `effect_edges:`, `effect_entry_points:`, all frozen value objects. Nothing here re-reads a manifest at
|
|
16
|
+
# scan time: the per-call lookup is two Hash reads and, for a class-name row, a walk up a superclass
|
|
17
|
+
# table that is a Hash read per level.
|
|
18
|
+
#
|
|
19
|
+
# **Nothing builds one unless collection is on.** {Plugin::Registry#effect_contributions} is itself
|
|
20
|
+
# lazy — a plugin MAY compute its rows from project facts, and rigor-activejob does — and this class is
|
|
21
|
+
# what turns the result into indices. {.build} is called from exactly one place on each side of the fork
|
|
22
|
+
# boundary, behind `configuration.effects_enabled?`, so a `rigor check` with no `effects:` block neither
|
|
23
|
+
# allocates one nor lets a plugin read a file for it.
|
|
24
|
+
#
|
|
25
|
+
# ## Two matching rules, because the framework has two shapes
|
|
26
|
+
#
|
|
27
|
+
# A **class-name** row (`ActiveRecord::Base#save`) matches through the project's inheritance chain:
|
|
28
|
+
# `user.save` on a `User < ApplicationRecord < ActiveRecord::Base` is the whole point, and matching the
|
|
29
|
+
# exact owner (which is what {Catalog} does, correctly, for Ruby's core) would find nothing. The chain
|
|
30
|
+
# walked is the **project's own** superclass table — the cross-file discovery pre-pass's
|
|
31
|
+
# `discovered_superclasses`, the same table `ExpressionTyper` walks for an unresolved implicit-self
|
|
32
|
+
# call. It stops where the project stops, which is exactly right: `ApplicationRecord < ActiveRecord::Base`
|
|
33
|
+
# is a line in `app/models/application_record.rb`, and a class that never names a framework base in
|
|
34
|
+
# project source is not one.
|
|
35
|
+
#
|
|
36
|
+
# A **receiver-path** row (`Rails.cache#read`) matches the receiver *expression* as written. `Rails.cache`
|
|
37
|
+
# returns an adapter-dependent object by design — memory, Redis, the filesystem — so there is no receiver
|
|
38
|
+
# class for a class-name row to key on, and the only stable handle is the path the programmer typed.
|
|
39
|
+
#
|
|
40
|
+
# ## What is deliberately not consulted
|
|
41
|
+
#
|
|
42
|
+
# The RBS ancestor chain. A gem's shipped signatures do carry `class User < ApplicationRecord`-shaped
|
|
43
|
+
# ancestry when the project generates RBS for its models, but reading it here would make a row's reach a
|
|
44
|
+
# function of whether the project happens to run `rbs prototype` — a plugin contribution that appears and
|
|
45
|
+
# disappears with an unrelated tool. The project's own `class … <` lines are the fact; a project whose
|
|
46
|
+
# models are declared only in RBS gets no plugin attribution and no taint, which is the fail-quiet
|
|
47
|
+
# direction.
|
|
48
|
+
class PluginFacts
|
|
49
|
+
# How far the superclass walk climbs before giving up. A project hierarchy deeper than this is either
|
|
50
|
+
# pathological or cyclic, and a cycle is possible in a project's *as-written* table.
|
|
51
|
+
ANCESTRY_CAP = 24
|
|
52
|
+
|
|
53
|
+
NO_ROWS = {}.freeze
|
|
54
|
+
private_constant :NO_ROWS
|
|
55
|
+
|
|
56
|
+
# One compiled attribution row.
|
|
57
|
+
#
|
|
58
|
+
# `discharge` is the RESOLVED grant, not the manifest's request: {.build} has already demoted a
|
|
59
|
+
# non-first-party plugin's `true` to `false` and recorded a warning, so nothing downstream has to
|
|
60
|
+
# re-ask who may discharge.
|
|
61
|
+
Row = Data.define(:key, :labels, :narrow, :discharge, :within, :taint, :plugin_id) do
|
|
62
|
+
def discharge? = discharge
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One compiled framework-edge strategy: the base class a plugin pointed the engine at, and which
|
|
66
|
+
# plugin pointed.
|
|
67
|
+
Edge = Data.define(:target, :receiver, :selector, :plugin_id)
|
|
68
|
+
|
|
69
|
+
def self.empty
|
|
70
|
+
@empty ||= new(contributions: [], superclasses: NO_ROWS)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param plugin_registry [Rigor::Plugin::Registry, nil]
|
|
74
|
+
# @param superclasses [Hash{String=>String,Array<String>}] the project's as-written superclass table
|
|
75
|
+
# (`Scope::DiscoveryIndex#discovered_superclasses`). Empty is legal and simply means no row matches
|
|
76
|
+
# through inheritance.
|
|
77
|
+
def self.build(plugin_registry, superclasses: NO_ROWS)
|
|
78
|
+
contributions = plugin_registry&.effect_contributions || []
|
|
79
|
+
return empty if contributions.empty?
|
|
80
|
+
|
|
81
|
+
new(contributions: contributions, superclasses: superclasses)
|
|
82
|
+
rescue StandardError
|
|
83
|
+
empty
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Human-readable notes about contributions that were accepted only in part — a third-party plugin's
|
|
87
|
+
# `discharge: true` demoted, a label root refused. Surfaced by `rigor effects`; never a diagnostic,
|
|
88
|
+
# because a plugin the user chose is not the project's mistake to be flagged for.
|
|
89
|
+
attr_reader :warnings
|
|
90
|
+
|
|
91
|
+
# `{owner => [labels]}` — what {#extend_registry} folds into the run's vocabulary.
|
|
92
|
+
attr_reader :labels_by_owner
|
|
93
|
+
|
|
94
|
+
# Every `effect_entry_points:` preset across the loaded set, in registration order.
|
|
95
|
+
attr_reader :entry_points
|
|
96
|
+
|
|
97
|
+
def initialize(contributions:, superclasses:)
|
|
98
|
+
@warnings = []
|
|
99
|
+
@class_rows = {}
|
|
100
|
+
@path_rows = {}
|
|
101
|
+
@self_rows = {}
|
|
102
|
+
@result_rows = {}
|
|
103
|
+
@edges = []
|
|
104
|
+
@labels_by_owner = {}
|
|
105
|
+
@entry_points = []
|
|
106
|
+
contributions.each { |contribution| absorb(contribution) }
|
|
107
|
+
@superclasses = superclasses || NO_ROWS
|
|
108
|
+
@ancestry = {}
|
|
109
|
+
@digest = compute_digest
|
|
110
|
+
finalize
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A content digest of every compiled plugin fact — labels, attributions, edges and presets, each with
|
|
114
|
+
# the plugin that contributed it. {Identity} folds it in, so upgrading a plugin whose rows moved
|
|
115
|
+
# invalidates the effects slot exactly as a re-audited `data/effects/core.yml` row does. Deliberately
|
|
116
|
+
# independent of the project's superclass table, which is a *project* input the diagnostics identity
|
|
117
|
+
# already covers.
|
|
118
|
+
attr_reader :digest
|
|
119
|
+
|
|
120
|
+
def empty?
|
|
121
|
+
!attributions? && @edges.empty? && @labels_by_owner.empty? && @entry_points.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Whether any attribution row exists at all — the scan's fast path, asked once per call site.
|
|
125
|
+
def attributions?
|
|
126
|
+
!@class_rows.empty? || !@path_rows.empty? || !@self_rows.empty? || !@result_rows.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def edges?
|
|
130
|
+
!@edges.empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The row colouring `owner`'s `selector`, found on `owner` itself or on a project ancestor of it.
|
|
134
|
+
#
|
|
135
|
+
# @param owner [String, nil] the receiver's class name as the syntax or the typer named it
|
|
136
|
+
# @param singleton [Boolean] whether the call is `Owner.selector`
|
|
137
|
+
# @return [Row, nil]
|
|
138
|
+
def class_row(owner, singleton, selector)
|
|
139
|
+
return nil if owner.nil? || @class_rows.empty?
|
|
140
|
+
|
|
141
|
+
bucket = @class_rows[singleton]
|
|
142
|
+
return nil if bucket.nil?
|
|
143
|
+
|
|
144
|
+
ancestry(owner).each do |candidate|
|
|
145
|
+
row = bucket[candidate]&.[](selector)
|
|
146
|
+
return row if row
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The row colouring `path`'s `selector`, where `path` is a receiver expression (`"Rails.cache"`).
|
|
152
|
+
# Exact — a receiver path names one object and has no ancestry to walk.
|
|
153
|
+
def path_row(path, selector)
|
|
154
|
+
return nil if path.nil? || @path_rows.empty?
|
|
155
|
+
|
|
156
|
+
@path_rows[path]&.[](selector)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The row colouring `path`'s `selector` for a receiver rooted at implicit self (`"self.flash.now"`),
|
|
160
|
+
# inside a unit whose class is `owner_class`. Answers nil when the row's `within:` class is not on
|
|
161
|
+
# `owner_class`'s project ancestry — a receiver-less `session` outside a controller is a different
|
|
162
|
+
# `session`.
|
|
163
|
+
def self_path_row(path, selector, owner_class)
|
|
164
|
+
return nil if path.nil? || @self_rows.empty?
|
|
165
|
+
|
|
166
|
+
row = @self_rows[path]&.[](selector)
|
|
167
|
+
return nil if row.nil?
|
|
168
|
+
return nil unless descends_from?(owner_class, row.within)
|
|
169
|
+
|
|
170
|
+
row
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The row colouring `selector` on the RESULT of a call to `producer` (or to a project ancestor of it):
|
|
174
|
+
# `UserMailer.welcome(u).deliver_now`, `WelcomeJob.set(wait: 1.hour).perform_later`. The lazy object
|
|
175
|
+
# in between has no declared type; the class that made it is written in the source.
|
|
176
|
+
def result_row(producer, selector)
|
|
177
|
+
return nil if producer.nil? || @result_rows.empty?
|
|
178
|
+
|
|
179
|
+
ancestry(producer).each do |candidate|
|
|
180
|
+
row = @result_rows[candidate]&.[](selector)
|
|
181
|
+
return row if row
|
|
182
|
+
end
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The framework-edge strategies of one kind, e.g. every `:activerecord_callbacks` base class the
|
|
187
|
+
# loaded plugins named.
|
|
188
|
+
def edges_for(target)
|
|
189
|
+
@edges.select { |edge| edge.target == target }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether `class_name` is `ancestor`, or reaches it through the project's own `class … <` lines.
|
|
193
|
+
def descends_from?(class_name, ancestor)
|
|
194
|
+
return false if class_name.nil?
|
|
195
|
+
|
|
196
|
+
ancestry(class_name).include?(ancestor)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# `registry` extended with every plugin's `effect_labels:`, each under its own owner so
|
|
200
|
+
# {Registry#with} enforces root ownership per plugin rather than for the set as a whole. A refusal is
|
|
201
|
+
# recorded as a warning and that plugin's labels are dropped; the rest of the run keeps its
|
|
202
|
+
# vocabulary, because one plugin overreaching must not un-name another's labels.
|
|
203
|
+
def extend_registry(registry)
|
|
204
|
+
@labels_by_owner.each do |owner, labels|
|
|
205
|
+
registry = registry.with(labels: labels, owner: owner)
|
|
206
|
+
rescue Registry::Error => e
|
|
207
|
+
@warnings << "effect labels from #{owner.inspect} were not registered: #{e.message}"
|
|
208
|
+
end
|
|
209
|
+
registry
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def absorb(contribution)
|
|
215
|
+
note_root_demotion(contribution)
|
|
216
|
+
(@labels_by_owner[contribution.owner] ||= []).concat(contribution.labels)
|
|
217
|
+
contribution.attributions.each { |entry| absorb_attribution(contribution, entry) }
|
|
218
|
+
contribution.edges.each do |entry|
|
|
219
|
+
@edges << Edge.new(target: entry.target, receiver: entry.receiver, selector: entry.method,
|
|
220
|
+
plugin_id: contribution.id)
|
|
221
|
+
end
|
|
222
|
+
@entry_points.concat(contribution.entry_points)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# ADR-103 WD2 — a plugin that asked to own a framework root and is not one the engine bundles keeps
|
|
226
|
+
# the root named after itself. Worth saying out loud: the labels still register, just under a
|
|
227
|
+
# different root, and a silent rename would look to the author like the labels vanished.
|
|
228
|
+
def note_root_demotion(contribution)
|
|
229
|
+
return if contribution.requested_root.nil? || contribution.requested_root == contribution.owner
|
|
230
|
+
|
|
231
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine and may not open " \
|
|
232
|
+
"the effect-label root #{contribution.requested_root.inspect}; its labels open " \
|
|
233
|
+
"#{contribution.owner.inspect} instead"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def absorb_attribution(contribution, entry)
|
|
237
|
+
row = Row.new(key: entry.key, labels: LabelSet.new(entry.labels), narrow: entry.narrow,
|
|
238
|
+
discharge: discharge_granted?(contribution, entry), within: entry.within,
|
|
239
|
+
taint: entry.taint, plugin_id: contribution.id)
|
|
240
|
+
(bucket_for(entry)[entry.receiver] ||= {})[entry.method.to_s] = row
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Which index a row lands in, from its receiver spelling.
|
|
244
|
+
def bucket_for(entry)
|
|
245
|
+
return @self_rows if entry.self_path?
|
|
246
|
+
return @path_rows if entry.receiver_path?
|
|
247
|
+
return @result_rows if entry.on_result
|
|
248
|
+
|
|
249
|
+
@class_rows[entry.singleton] ||= {}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# ADR-103 WD6 — discharge is a grant, and the granting fact is "the engine bundles this plugin".
|
|
253
|
+
def discharge_granted?(contribution, entry)
|
|
254
|
+
return false unless entry.discharge
|
|
255
|
+
return true if contribution.discharge_allowed
|
|
256
|
+
|
|
257
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
|
|
258
|
+
"#{entry.key} attribution does not discharge the call site's taint"
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the table is *as written*,
|
|
263
|
+
# so a project can spell one that loops.
|
|
264
|
+
def ancestry(class_name)
|
|
265
|
+
@ancestry[class_name] ||= begin
|
|
266
|
+
chain = []
|
|
267
|
+
seen = Set.new
|
|
268
|
+
current = class_name
|
|
269
|
+
while current && seen.add?(current) && chain.length < ANCESTRY_CAP
|
|
270
|
+
chain << current
|
|
271
|
+
current = Array(@superclasses[current]).first
|
|
272
|
+
end
|
|
273
|
+
chain.freeze
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def compute_digest
|
|
278
|
+
payload = [
|
|
279
|
+
@labels_by_owner.sort.map { |owner, labels| [owner, labels.sort] },
|
|
280
|
+
@class_rows.keys.sort_by { |singleton| singleton ? 1 : 0 }
|
|
281
|
+
.map { |singleton| [singleton, sorted(@class_rows[singleton])] },
|
|
282
|
+
sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
|
|
283
|
+
@edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
|
|
284
|
+
@entry_points.map(&:to_h).sort_by { |preset| preset["name"] }
|
|
285
|
+
]
|
|
286
|
+
Digest::SHA256.hexdigest(payload.inspect)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# ADR-103 WD14 — `effects.snapshot.reach: [rails]` adopts a preset BY NAME, so the names have to be
|
|
290
|
+
# registered somewhere between plugin load and snapshot build. Here is that somewhere: this object is
|
|
291
|
+
# built once per process from the loaded plugin set, which is exactly the condition the registration
|
|
292
|
+
# needs. {EntryPoints.register} is idempotent for an identical glob set, so a second run in one
|
|
293
|
+
# process is a no-op; two plugins claiming one name with DIFFERENT globs is a genuine conflict and
|
|
294
|
+
# becomes a warning rather than taking the run down over a `reach:` key nobody may have used.
|
|
295
|
+
def register_entry_points
|
|
296
|
+
return if @entry_points.empty?
|
|
297
|
+
|
|
298
|
+
EntryPoints.register_all(@entry_points)
|
|
299
|
+
rescue EntryPoints::Error => e
|
|
300
|
+
@warnings << "entry-point preset not registered: #{e.message}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def sorted(bucket)
|
|
304
|
+
bucket.sort.map do |receiver, rows|
|
|
305
|
+
[receiver,
|
|
306
|
+
rows.sort.map do |selector, row|
|
|
307
|
+
[selector, row.labels.to_a, row.narrow, row.discharge, row.within, row.taint]
|
|
308
|
+
end]
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def finalize
|
|
313
|
+
@class_rows.each_value { |bucket| bucket.each_value(&:freeze) }
|
|
314
|
+
@class_rows.each_value(&:freeze)
|
|
315
|
+
@class_rows.freeze
|
|
316
|
+
@path_rows.each_value(&:freeze)
|
|
317
|
+
@path_rows.freeze
|
|
318
|
+
@self_rows.each_value(&:freeze)
|
|
319
|
+
@self_rows.freeze
|
|
320
|
+
@result_rows.each_value(&:freeze)
|
|
321
|
+
@result_rows.freeze
|
|
322
|
+
@edges.freeze
|
|
323
|
+
@labels_by_owner.each_value(&:uniq!)
|
|
324
|
+
@labels_by_owner.reject! { |_, labels| labels.empty? }
|
|
325
|
+
@labels_by_owner.freeze
|
|
326
|
+
@entry_points.freeze
|
|
327
|
+
register_entry_points
|
|
328
|
+
# `@warnings` and `@ancestry` stay mutable: the first collects the registry-extension refusals that
|
|
329
|
+
# can only be known when a vocabulary is folded, and the second is a per-process memo. The object is
|
|
330
|
+
# therefore not frozen — it is process-local by construction (each fork-pool worker builds its own),
|
|
331
|
+
# and nothing marshals it.
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "discharge"
|
|
4
|
+
require_relative "effect_table"
|
|
5
|
+
require_relative "file_collection"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Closes a run's collected summaries over the call graph (ADR-103 WD12).
|
|
10
|
+
#
|
|
11
|
+
# Two jobs, in order:
|
|
12
|
+
#
|
|
13
|
+
# 1. **Resolve edges.** The collector records a call as `(receiver class, kind, selector)`; a file
|
|
14
|
+
# cannot say which definition that reaches, because the class graph spans the project. Here the
|
|
15
|
+
# merged ancestry resolves it — the receiver's own class first, then its includes, then its
|
|
16
|
+
# superclass chain — and then the **closed world** joins every project-known override of the same
|
|
17
|
+
# selector below the receiver's class (ADR-103 WD4: Ruby has no `final`, and the analyzer already
|
|
18
|
+
# takes this posture for types). A call that resolves to nothing in the project is dropped, not
|
|
19
|
+
# tainted: the taint for an unresolvable call was already decided per site, from the typer's own
|
|
20
|
+
# verdict.
|
|
21
|
+
# 2. **Reach a fixpoint.** Proven labels join along edges, the exhaustiveness bit ANDs, and causes
|
|
22
|
+
# union. The lattice is finite (label sets over a closed vocabulary × one bit × a closed cause
|
|
23
|
+
# enum) and every step is monotone, so iteration terminates on its own — a recursive or mutually
|
|
24
|
+
# recursive cycle simply converges, and no recursion cap is needed or wanted here.
|
|
25
|
+
#
|
|
26
|
+
# Propagation is graph-only: it reads no source, types nothing, and touches no `Scope`. It is
|
|
27
|
+
# fail-soft as a whole — an exception yields the empty table rather than failing the run.
|
|
28
|
+
#
|
|
29
|
+
# **Three label lanes, one fixpoint** (#385), each monotone and each joined along the same edges:
|
|
30
|
+
#
|
|
31
|
+
# - `proven` — what the analyzer established.
|
|
32
|
+
# - `undischarged` — the same closure computed from each unit's *undischarged* direct bundles, i.e.
|
|
33
|
+
# with every origin bundle `effects.tolerated:` discharges dropped at the seed ({Discharge}).
|
|
34
|
+
# Per-origin discharge needs nothing more than that: an origin belongs to exactly one unit's direct
|
|
35
|
+
# summary, so the transitive union of surviving bundles IS the closure of the seeded ones, and the
|
|
36
|
+
# judgment never has to materialise a per-method set of transitive origins. With no `tolerated:`
|
|
37
|
+
# list the policy is inert, the seed is identical, and the lane costs one `equal?`-true join.
|
|
38
|
+
# - `declared` — the `≤` lane. It travels call edges **exactly as the proven lane does** (ADR-103
|
|
39
|
+
# WD1): a controller two hops above an attributed `Net::HTTP.get` reads `≤ io.net.http`, not merely
|
|
40
|
+
# "and possibly more". It is joined into itself and never into `proven`, which is the whole
|
|
41
|
+
# separation — a claim stays a claim however far it propagates.
|
|
42
|
+
module Propagator
|
|
43
|
+
NO_EDGES = [].freeze
|
|
44
|
+
private_constant :NO_EDGES
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# @param collection [FileCollection] the run's merged per-file collections
|
|
49
|
+
# @param discharge [Discharge] the `effects.tolerated:` policy the undischarged lane is computed
|
|
50
|
+
# under; {Discharge.none} makes the two lanes equal.
|
|
51
|
+
# @return [EffectTable]
|
|
52
|
+
def propagate(collection, discharge: Discharge.none)
|
|
53
|
+
return EffectTable.empty if collection.summaries.empty?
|
|
54
|
+
|
|
55
|
+
summaries = collection.summaries
|
|
56
|
+
edges = resolve_edges(collection)
|
|
57
|
+
state = seed(summaries, discharge)
|
|
58
|
+
iterate(state, edges)
|
|
59
|
+
EffectTable.new(build_entries(summaries, edges, state))
|
|
60
|
+
rescue StandardError
|
|
61
|
+
EffectTable.empty
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `{caller_key => [callee_key]}`, sorted and de-duplicated.
|
|
65
|
+
def resolve_edges(collection)
|
|
66
|
+
index = Index.new(collection)
|
|
67
|
+
collection.edges.each_with_object({}) do |(caller_key, list), out|
|
|
68
|
+
targets = list.flat_map { |edge| index.targets_for(edge) }.uniq.sort
|
|
69
|
+
out[caller_key] = targets.freeze unless targets.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
|
|
74
|
+
# {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
|
|
75
|
+
# size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
|
|
76
|
+
# allocated twice on every visit of every edge.
|
|
77
|
+
def seed(summaries, discharge)
|
|
78
|
+
summaries.transform_values do |summary|
|
|
79
|
+
{
|
|
80
|
+
proven: summary.proven,
|
|
81
|
+
undischarged: discharge.inert? ? summary.proven : discharge.undischarged(summary.bundles),
|
|
82
|
+
declared: summary.declared,
|
|
83
|
+
exhaustive: summary.exhaustive?, causes: Set.new(summary.causes)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A worklist to a fixpoint. `state[key]` changing can only change the methods that CALL `key`, so a
|
|
89
|
+
# pass re-visits exactly those and the round-robin over the whole table is gone — that walk cost
|
|
90
|
+
# O(passes × edges) and the passes are the graph's depth.
|
|
91
|
+
#
|
|
92
|
+
# Each pass still runs in sorted key order, so the answer does not depend on Hash insertion order
|
|
93
|
+
# and a pooled run agrees with a sequential one bit for bit. (The lattice is finite and every step
|
|
94
|
+
# monotone, so the least fixpoint is unique and visit order cannot change it; the sorted pass keeps
|
|
95
|
+
# the *work* reproducible too.)
|
|
96
|
+
def iterate(state, edges)
|
|
97
|
+
order = state.keys.sort
|
|
98
|
+
callers = reverse_edges(edges)
|
|
99
|
+
pending = nil
|
|
100
|
+
|
|
101
|
+
loop do
|
|
102
|
+
dirty = nil
|
|
103
|
+
order.each do |key|
|
|
104
|
+
next if pending && !pending.include?(key)
|
|
105
|
+
|
|
106
|
+
list = edges[key]
|
|
107
|
+
next if list.nil?
|
|
108
|
+
|
|
109
|
+
changed = false
|
|
110
|
+
list.each { |callee| changed = true if absorb(state, key, callee) }
|
|
111
|
+
next unless changed
|
|
112
|
+
|
|
113
|
+
(dirty ||= Set.new).merge(callers[key]) if callers.key?(key)
|
|
114
|
+
end
|
|
115
|
+
break if dirty.nil?
|
|
116
|
+
|
|
117
|
+
pending = dirty
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `{callee_key => [caller_key]}` — who has to be re-visited when a key's closure moves.
|
|
122
|
+
def reverse_edges(edges)
|
|
123
|
+
edges.each_with_object({}) do |(caller_key, list), out|
|
|
124
|
+
list.each { |callee| (out[callee] ||= []) << caller_key }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def absorb(state, key, callee)
|
|
129
|
+
target = state[key]
|
|
130
|
+
source = state[callee]
|
|
131
|
+
return false if source.nil? || target.equal?(source)
|
|
132
|
+
|
|
133
|
+
# Each lane is named literally rather than looped over an array: this runs once per edge per
|
|
134
|
+
# visit, and a literal array of lane names would allocate one per call for nothing.
|
|
135
|
+
changed = join_lane(target, source, :proven)
|
|
136
|
+
changed = true if join_lane(target, source, :undischarged)
|
|
137
|
+
changed = true if join_lane(target, source, :declared)
|
|
138
|
+
if target[:exhaustive] && !source[:exhaustive]
|
|
139
|
+
target[:exhaustive] = false
|
|
140
|
+
changed = true
|
|
141
|
+
end
|
|
142
|
+
causes = target[:causes]
|
|
143
|
+
source[:causes].each { |cause| changed = true if causes.add?(cause) }
|
|
144
|
+
changed
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Joins one label lane in place along an edge, answering whether it moved. {LabelSet#join} returns
|
|
148
|
+
# the receiver untouched when the source adds nothing, so a converged region costs a comparison and
|
|
149
|
+
# no allocation at all.
|
|
150
|
+
def join_lane(target, source, lane)
|
|
151
|
+
joined = target[lane].join(source[lane])
|
|
152
|
+
moved = joined != target[lane]
|
|
153
|
+
target[lane] = joined if moved
|
|
154
|
+
moved
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_entries(summaries, edges, state)
|
|
158
|
+
summaries.each_with_object({}) do |(key, summary), out|
|
|
159
|
+
closed = state.fetch(key)
|
|
160
|
+
out[key] = EffectTable::Entry.new(
|
|
161
|
+
key: key,
|
|
162
|
+
direct: summary,
|
|
163
|
+
proven: closed[:proven],
|
|
164
|
+
undischarged: closed[:undischarged],
|
|
165
|
+
declared: closed[:declared],
|
|
166
|
+
exhaustive: closed[:exhaustive],
|
|
167
|
+
causes: closed[:causes].sort_by { |cause, detail| [cause, detail.to_s] }.freeze,
|
|
168
|
+
edges: edges.fetch(key, NO_EDGES)
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private_class_method :resolve_edges, :seed, :iterate, :reverse_edges, :absorb, :join_lane,
|
|
174
|
+
:build_entries
|
|
175
|
+
|
|
176
|
+
# The class graph a run's collections describe, and the edge resolution over it. Built once per
|
|
177
|
+
# propagation; every lookup is a Hash read.
|
|
178
|
+
class Index
|
|
179
|
+
def initialize(collection)
|
|
180
|
+
@summaries = collection.summaries
|
|
181
|
+
@superclasses = collection.superclasses
|
|
182
|
+
@includes = collection.includes
|
|
183
|
+
@classes = build_classes(collection)
|
|
184
|
+
@descendants = build_descendants(collection.superclasses)
|
|
185
|
+
@descendant_closures = {}
|
|
186
|
+
@targets = {}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
|
|
190
|
+
# override of the same selector in a project subclass of the receiver's class.
|
|
191
|
+
#
|
|
192
|
+
# Memoised on `(receiver class, kind, selector)` — the answer depends on nothing else, and one
|
|
193
|
+
# such triple is asked for once per call site in the project. `ApplicationRecord#save` alone is
|
|
194
|
+
# thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
|
|
195
|
+
def targets_for(edge)
|
|
196
|
+
@targets[[edge.receiver_class, edge.kind, edge.selector]] ||= begin
|
|
197
|
+
separator = edge.kind == :singleton ? "." : "#"
|
|
198
|
+
targets = []
|
|
199
|
+
owner = resolve_owner(edge.receiver_class, separator, edge.selector)
|
|
200
|
+
targets << owner if owner
|
|
201
|
+
descendant_closure(edge.receiver_class).each do |subclass|
|
|
202
|
+
key = "#{subclass}#{separator}#{edge.selector}"
|
|
203
|
+
targets << key if @summaries.key?(key)
|
|
204
|
+
end
|
|
205
|
+
targets.freeze
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
# The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
|
|
212
|
+
# once, not once per selector reaching it.
|
|
213
|
+
def descendant_closure(class_name)
|
|
214
|
+
@descendant_closures[class_name] ||= descendants_of(class_name).freeze
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Ancestry order mirrors the engine's: the class itself, the modules it includes, then its
|
|
218
|
+
# superclass, recursively. Cycle-guarded, because a project may declare one. Ancestry names
|
|
219
|
+
# arrive as as-written candidate lists (see `Scanner#lexical_candidates`); every candidate is
|
|
220
|
+
# enqueued and the most-qualified one comes first, so the right constant wins the race and a
|
|
221
|
+
# spelling that names nothing simply matches no key.
|
|
222
|
+
def resolve_owner(class_name, separator, selector)
|
|
223
|
+
seen = Set.new
|
|
224
|
+
queue = [class_name]
|
|
225
|
+
until queue.empty?
|
|
226
|
+
current = queue.shift
|
|
227
|
+
next if current.nil? || !seen.add?(current)
|
|
228
|
+
|
|
229
|
+
key = "#{current}#{separator}#{selector}"
|
|
230
|
+
return key if @summaries.key?(key)
|
|
231
|
+
|
|
232
|
+
queue.concat(@includes.fetch(current, []))
|
|
233
|
+
queue.concat(@superclasses.fetch(current, []))
|
|
234
|
+
end
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def descendants_of(class_name)
|
|
239
|
+
collected = []
|
|
240
|
+
queue = @descendants.fetch(class_name, []).dup
|
|
241
|
+
seen = Set.new
|
|
242
|
+
until queue.empty?
|
|
243
|
+
current = queue.shift
|
|
244
|
+
next unless seen.add?(current)
|
|
245
|
+
|
|
246
|
+
collected << current
|
|
247
|
+
queue.concat(@descendants.fetch(current, []))
|
|
248
|
+
end
|
|
249
|
+
collected
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# The subclass index the closed-world override join walks. Unlike the ancestor walk, this one
|
|
253
|
+
# must pick **one** parent per child: enqueuing every candidate would let `A::Base` and `B::Base`
|
|
254
|
+
# share the short spelling `Base` and join an unrelated class's override into the proven lane.
|
|
255
|
+
# The most-qualified candidate the project actually defines wins; a child whose parent is outside
|
|
256
|
+
# the project keeps its first (most-qualified) spelling and simply matches nothing.
|
|
257
|
+
def build_descendants(superclasses)
|
|
258
|
+
superclasses.each_with_object({}) do |(child, candidates), out|
|
|
259
|
+
parent = candidates.find { |candidate| @classes.include?(candidate) } || candidates.first
|
|
260
|
+
(out[parent] ||= []) << child
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Every class name the project defines a method on — the evidence `build_descendants` resolves an
|
|
265
|
+
# as-written superclass against.
|
|
266
|
+
def build_classes(collection)
|
|
267
|
+
collection.summaries.each_key.with_object(Set.new) do |key, out|
|
|
268
|
+
index = key.index("#") || key.index(".")
|
|
269
|
+
out << key[0, index] if index
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|