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,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "../inference/mutation_widening"
|
|
7
|
+
require_relative "label_set"
|
|
8
|
+
require_relative "mutation_classifier"
|
|
9
|
+
require_relative "narrowing"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
module Effects
|
|
13
|
+
# The built-in effect catalogue — which core-library methods colour a summary, and with what
|
|
14
|
+
# (ADR-103 WD3 / WD14; the data is `data/effects/core.yml`, the contract is
|
|
15
|
+
# `docs/internal-spec/effect-summaries.md` § The catalogue).
|
|
16
|
+
#
|
|
17
|
+
# **The loader is deliberately dumb.** Every audit decision lives in the data file's `why:` lines;
|
|
18
|
+
# nothing here decides what a method does. What this class adds over `YAML.safe_load` is three
|
|
19
|
+
# things the data cannot express on its own:
|
|
20
|
+
#
|
|
21
|
+
# 1. **Per-class default postures.** A class the catalogue lists answers its posture's labels for a
|
|
22
|
+
# method it does not row, so the reading of Ruby's enormous core surface does not depend on the
|
|
23
|
+
# catalogue being complete. A class the catalogue does NOT list answers nil — contribute nothing,
|
|
24
|
+
# do not taint — which stays the reading for project and gem classes.
|
|
25
|
+
# 2. **Argument-dependent narrowing.** A row's `narrow:` names a {Narrowing} handler, which reads
|
|
26
|
+
# the call's own argument literals.
|
|
27
|
+
# 3. **Mutator sets by reference.** A value class names `mutators: array | hash | string` and the
|
|
28
|
+
# loader resolves it to the set the widening rules and the mutation classifier already maintain,
|
|
29
|
+
# so the two can never drift. The YAML never re-spells a mutator list.
|
|
30
|
+
#
|
|
31
|
+
# What this is **not** is a re-reading of `data/builtins/ruby_core/*.yml`'s `purity:` facet. That
|
|
32
|
+
# facet answers fold-safety in the C-dispatch sense — `Random#rand` is `leaf`, `Array#push` is
|
|
33
|
+
# `leaf` — and reading it as effect freedom would be wrong in both directions (ADR-103 WD3). This
|
|
34
|
+
# loader never opens `data/builtins`, and a spec asserts it.
|
|
35
|
+
class Catalog
|
|
36
|
+
class Error < StandardError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__)
|
|
40
|
+
|
|
41
|
+
# The mutator sets a value class may name, by reference. Adding a name here is the only way a
|
|
42
|
+
# class gets one — the data file may not spell a selector list of its own.
|
|
43
|
+
MUTATOR_SETS = {
|
|
44
|
+
"array" => Inference::MutationWidening::ARRAY_MUTATORS,
|
|
45
|
+
"hash" => Inference::MutationWidening::HASH_MUTATORS,
|
|
46
|
+
"string" => MutationClassifier::STRING_MUTATORS
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
NO_MUTATORS = Set[].freeze
|
|
50
|
+
private_constant :NO_MUTATORS
|
|
51
|
+
|
|
52
|
+
# What the catalogue answers for one call.
|
|
53
|
+
#
|
|
54
|
+
# `labels` may be empty two ways, and the difference matters: a ∅ **row** says the catalogue knows
|
|
55
|
+
# this call and knows it contributes nothing (which is what stops `Thread.new` from reading as an
|
|
56
|
+
# unresolved call while its block joins the enclosing method by containment), while a ∅ **posture**
|
|
57
|
+
# says only that the class is a value class. Both stop the caller treating the call as unresolved;
|
|
58
|
+
# only the posture keeps a project edge, because a project may reopen a core class.
|
|
59
|
+
class Entry < Data.define(:labels, :mutates_receiver, :from_posture)
|
|
60
|
+
def posture?
|
|
61
|
+
from_posture
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mutates_receiver?
|
|
65
|
+
mutates_receiver
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# One row of the data file, resolved. `entry` is the row's own unnarrowed {Entry}, built at load
|
|
70
|
+
# time: the lookup sits inside the effect scan's walk, and every un-narrowed row hit in a project
|
|
71
|
+
# answers the same value.
|
|
72
|
+
Row = Data.define(:labels, :narrow, :mutates_receiver, :entry)
|
|
73
|
+
private_constant :Row
|
|
74
|
+
|
|
75
|
+
# One class of the data file, resolved: its two method buckets, its posture's labels, its mutator
|
|
76
|
+
# set, and the two memoised posture {Entry}s (allocated once rather than per call — the lookup
|
|
77
|
+
# sits inside the effect scan's walk).
|
|
78
|
+
class ClassEntry
|
|
79
|
+
attr_reader :instance_methods, :singleton_methods, :posture_labels, :singleton_posture_labels,
|
|
80
|
+
:mutators, :object
|
|
81
|
+
|
|
82
|
+
def initialize(instance_methods:, singleton_methods:, posture_labels:, singleton_posture_labels:,
|
|
83
|
+
mutators:, object:)
|
|
84
|
+
@instance_methods = instance_methods
|
|
85
|
+
@singleton_methods = singleton_methods
|
|
86
|
+
@posture_labels = posture_labels
|
|
87
|
+
@singleton_posture_labels = singleton_posture_labels
|
|
88
|
+
@mutators = mutators
|
|
89
|
+
@object = object
|
|
90
|
+
@entries = {
|
|
91
|
+
[false, false] => Entry.new(labels: posture_labels, mutates_receiver: false, from_posture: true),
|
|
92
|
+
[false, true] => Entry.new(labels: posture_labels, mutates_receiver: true, from_posture: true),
|
|
93
|
+
[true, false] => Entry.new(labels: singleton_posture_labels, mutates_receiver: false,
|
|
94
|
+
from_posture: true)
|
|
95
|
+
}.freeze
|
|
96
|
+
freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def object?
|
|
100
|
+
@object
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Memoised rather than built per call — the lookup sits inside the effect scan's walk. Only the
|
|
104
|
+
# instance side can be a receiver mutation, so the singleton side needs one entry.
|
|
105
|
+
def posture_entry(singleton, mutating)
|
|
106
|
+
@entries[[singleton, !singleton && mutating]]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The shipped catalogue. Memoised: the YAML parse is a once-per-process cost, paid on the first
|
|
111
|
+
# collecting run rather than at `require "rigor"`, and inherited as-is across the fork pool
|
|
112
|
+
# (workers fork after load, and nothing here ever crosses a Marshal boundary — a `FileCollection`
|
|
113
|
+
# carries `LabelSet`s and Strings, never a catalogue reference).
|
|
114
|
+
def self.default
|
|
115
|
+
@default ||= load_file(DATA_PATH)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build a catalogue from a catalogue-shaped YAML file. A missing or unreadable file degrades to an
|
|
119
|
+
# empty catalogue rather than raising, matching {Registry.load_file} and the built-in catalogues'
|
|
120
|
+
# posture for a bare install that opted data out: every call then reads as uncatalogued, which is
|
|
121
|
+
# fail-open. A file that IS present but malformed raises — that is a packaging bug, not a choice.
|
|
122
|
+
def self.load_file(path)
|
|
123
|
+
raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
|
|
124
|
+
raw = {} unless raw.is_a?(Hash)
|
|
125
|
+
new(
|
|
126
|
+
defaults: raw["defaults"] || {}, classes: raw["classes"] || {},
|
|
127
|
+
universal: raw["universal"] || [], schema: raw.fetch("schema", 0),
|
|
128
|
+
digest: file_digest(path)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A content digest of the catalogue file, so {#identity} moves when a row's audit decision moves.
|
|
133
|
+
# `schema:` alone cannot carry that: it is bumped for a shape change, and the whole point of the
|
|
134
|
+
# data file is that rows are edited without one. An absent file digests as `"absent"`, matching
|
|
135
|
+
# {load_file}'s fail-open posture for a bare install that opted data out.
|
|
136
|
+
def self.file_digest(path)
|
|
137
|
+
File.file?(path) ? Digest::SHA256.file(path).hexdigest : "absent"
|
|
138
|
+
rescue StandardError
|
|
139
|
+
"unreadable"
|
|
140
|
+
end
|
|
141
|
+
private_class_method :file_digest
|
|
142
|
+
|
|
143
|
+
attr_reader :schema, :digest
|
|
144
|
+
|
|
145
|
+
def initialize(defaults:, classes:, universal: [], schema: 0, digest: "unknown")
|
|
146
|
+
@schema = schema
|
|
147
|
+
@digest = digest.to_s.freeze
|
|
148
|
+
@postures = build_postures(defaults)
|
|
149
|
+
@universal = universal.to_set(&:to_s).freeze
|
|
150
|
+
@classes = build_classes(classes)
|
|
151
|
+
@object_constants = @classes.select { |_, entry| entry.object? }.keys.to_set.freeze
|
|
152
|
+
freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# What the effects cache identity records this catalogue as (#382, {Effects::Identity}): the schema
|
|
156
|
+
# AND the content digest, so a re-audited row invalidates persisted summaries the same way a shape
|
|
157
|
+
# change does.
|
|
158
|
+
def identity
|
|
159
|
+
"#{@schema}:#{@digest}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Every catalogued class name, sorted — the surface a spec walks.
|
|
163
|
+
def class_names
|
|
164
|
+
@classes.keys.sort
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def class_entry(owner)
|
|
168
|
+
@classes[owner]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Whether `name` is a constant that names an OBJECT rather than a class, so a call on it spells
|
|
172
|
+
# `ENV#[]` and not `ENV.[]`.
|
|
173
|
+
def object_constant?(name)
|
|
174
|
+
@object_constants.include?(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# What `owner#name` (or `owner.name`, with `singleton:`) contributes, or nil when the catalogue
|
|
178
|
+
# has nothing to say — which is not a taint, and leaves the caller to treat the call as ordinary.
|
|
179
|
+
#
|
|
180
|
+
# `call_node` is the Prism call, consulted only by a row that carries a `narrow:` handler.
|
|
181
|
+
# `posture:` false asks for a ROW ONLY, which is how the collector suppresses the class default
|
|
182
|
+
# where it would be wrong: an implicit-self call spells `Kernel#name`, and defaulting every
|
|
183
|
+
# unqualified call in a project body to `io` would colour the world.
|
|
184
|
+
def lookup(owner, name, singleton: false, call_node: nil, posture: true)
|
|
185
|
+
entry = @classes[owner]
|
|
186
|
+
return nil if entry.nil?
|
|
187
|
+
|
|
188
|
+
bucket = singleton ? entry.singleton_methods : entry.instance_methods
|
|
189
|
+
row = bucket[name]
|
|
190
|
+
return row_entry(row, call_node) if row
|
|
191
|
+
return UNIVERSAL if @universal.include?(name)
|
|
192
|
+
return nil unless posture
|
|
193
|
+
|
|
194
|
+
entry.posture_entry(singleton, entry.mutators.include?(name))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# An `Object`-level selector that exists on every receiver and touches nothing. Answered as a row
|
|
198
|
+
# rather than a posture, because it IS a statement about the selector.
|
|
199
|
+
UNIVERSAL = Entry.new(labels: LabelSet::EMPTY, mutates_receiver: false, from_posture: false)
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# A narrowed row's own `effects:` is its **unnarrowed** answer — the upper bound the handler
|
|
204
|
+
# degrades to — so a caller that has no call node still gets a sound reading rather than ∅.
|
|
205
|
+
def row_entry(row, call_node)
|
|
206
|
+
return row.entry unless row.narrow && call_node
|
|
207
|
+
|
|
208
|
+
Entry.new(labels: Narrowing.apply(row.narrow, call_node), mutates_receiver: row.mutates_receiver,
|
|
209
|
+
from_posture: false)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def build_postures(defaults)
|
|
213
|
+
defaults.to_h { |name, labels| [name.to_s, LabelSet.new(Array(labels).map(&:to_s))] }.freeze
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_classes(classes)
|
|
217
|
+
classes.to_h { |name, body| [name.to_s, build_class(name.to_s, body || {})] }.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_class(name, body)
|
|
221
|
+
mutators = resolve_mutators(name, body["mutators"])
|
|
222
|
+
ClassEntry.new(
|
|
223
|
+
instance_methods: build_rows(name, body["methods"], mutators),
|
|
224
|
+
singleton_methods: build_rows(name, body["singleton_methods"], nil),
|
|
225
|
+
posture_labels: resolve_posture(name, body["posture"]),
|
|
226
|
+
singleton_posture_labels: resolve_posture(name, body["singleton_posture"] || body["posture"]),
|
|
227
|
+
mutators: mutators,
|
|
228
|
+
object: body["kind"] == "object"
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def resolve_posture(class_name, posture)
|
|
233
|
+
return LabelSet::EMPTY if posture.nil?
|
|
234
|
+
|
|
235
|
+
@postures[posture.to_s] ||
|
|
236
|
+
raise(Error, "#{class_name}: unknown posture #{posture.inspect}; add it to the catalogue's defaults:")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def resolve_mutators(class_name, named)
|
|
240
|
+
return NO_MUTATORS if named.nil?
|
|
241
|
+
|
|
242
|
+
set = MUTATOR_SETS[named.to_s] ||
|
|
243
|
+
raise(Error, "#{class_name}: unknown mutator set #{named.inspect}")
|
|
244
|
+
set.to_set(&:to_s).freeze
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def build_rows(class_name, rows, mutators)
|
|
248
|
+
return EMPTY_ROWS if rows.nil?
|
|
249
|
+
|
|
250
|
+
rows.to_h do |name, body|
|
|
251
|
+
key = name.to_s
|
|
252
|
+
[key, build_row("#{class_name}##{key}", body || {}, mutators&.include?(key) == true)]
|
|
253
|
+
end.freeze
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def build_row(key, body, in_mutator_set)
|
|
257
|
+
narrow = body["narrow"]&.to_s
|
|
258
|
+
raise Error, "#{key}: unknown narrowing handler #{narrow.inspect}" if narrow && !Narrowing.known?(narrow)
|
|
259
|
+
raise Error, "#{key}: every catalogue row needs a `why:` justification" if body["why"].to_s.empty?
|
|
260
|
+
|
|
261
|
+
labels = LabelSet.new(Array(body["effects"]).map(&:to_s))
|
|
262
|
+
mutates_receiver = body["mutates"].to_s == "receiver" || in_mutator_set
|
|
263
|
+
Row.new(
|
|
264
|
+
labels: labels,
|
|
265
|
+
narrow: narrow,
|
|
266
|
+
mutates_receiver: mutates_receiver,
|
|
267
|
+
entry: Entry.new(labels: labels, mutates_receiver: mutates_receiver, from_posture: false)
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
EMPTY_ROWS = {}.freeze
|
|
272
|
+
private_constant :EMPTY_ROWS
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../type"
|
|
4
|
+
require_relative "../inference/origin_lookup"
|
|
5
|
+
require_relative "attribution"
|
|
6
|
+
require_relative "envelope_index"
|
|
7
|
+
require_relative "file_collection"
|
|
8
|
+
require_relative "plugin_facts"
|
|
9
|
+
require_relative "scanner"
|
|
10
|
+
|
|
11
|
+
module Rigor
|
|
12
|
+
module Effects
|
|
13
|
+
# Records what the typer already decided about each call site, and turns one file's decisions into a
|
|
14
|
+
# {FileCollection} (ADR-103 WD13; the contract is `docs/internal-spec/effect-summaries.md`).
|
|
15
|
+
#
|
|
16
|
+
# Modelled on {Rigor::Analysis::DependencyRecorder}, for the same reason: the recording site sits on
|
|
17
|
+
# the per-dispatch hot path, so **the disabled fast path must be a plain integer read**. A
|
|
18
|
+
# module-level activation count answers {active?} without a `Thread.current` lookup; the per-thread
|
|
19
|
+
# accumulator isolates the actual recording, so a non-recording thread that sees `active?` true (a
|
|
20
|
+
# sibling thread is recording) pays one extra nil-check and nothing else.
|
|
21
|
+
#
|
|
22
|
+
# The recorder is **observational**. It never triggers inference, never resolves what the dispatcher
|
|
23
|
+
# declined, and never touches `Scope` — it reads the receiver type the typer computed anyway and the
|
|
24
|
+
# `dynamic_origins` cause the typer already recorded. With no `effects:` block nothing calls
|
|
25
|
+
# {collect_for}, `@active_count` stays zero, and `rigor check` is byte-identical.
|
|
26
|
+
module Collector
|
|
27
|
+
KEY = :__rigor_effects_collector__
|
|
28
|
+
private_constant :KEY
|
|
29
|
+
|
|
30
|
+
# What one call site decided, as the typer saw it.
|
|
31
|
+
#
|
|
32
|
+
# `receiver_class` / `kind` are the class the receiver's type projects to (`Nominal[Foo]` →
|
|
33
|
+
# `["Foo", :instance]`, `Singleton[Time]` → `["Time", :singleton]`), nil when it projects to none.
|
|
34
|
+
# `dynamic` marks a `Dynamic` receiver and `cause` carries the {Inference::DynamicOrigin} name
|
|
35
|
+
# behind it, which is the detail the `dynamic-receiver` taint reports. `resolved` is false only when
|
|
36
|
+
# every dispatch tier declined — the typer's own "nothing here resolved" verdict, which is what
|
|
37
|
+
# separates an unresolvable implicit-self call from an ordinary one.
|
|
38
|
+
CallRecord = Data.define(:receiver_class, :kind, :dynamic, :cause, :resolved)
|
|
39
|
+
|
|
40
|
+
# Per-file, per-thread accumulator. Not frozen and never shared: it lives for one `analyze_file`.
|
|
41
|
+
class Accumulator
|
|
42
|
+
attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts
|
|
43
|
+
attr_accessor :root
|
|
44
|
+
|
|
45
|
+
def initialize(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
46
|
+
plugin_facts: PluginFacts.empty)
|
|
47
|
+
@path = path
|
|
48
|
+
@attribution = attribution
|
|
49
|
+
@envelopes = envelopes
|
|
50
|
+
@plugin_facts = plugin_facts
|
|
51
|
+
# Node identity, not equality: two structurally identical call nodes in one file are different
|
|
52
|
+
# sites, and `Prism::Node#hash` is structural.
|
|
53
|
+
@calls = {}.compare_by_identity
|
|
54
|
+
@root = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# First write wins. The main typing pass records a site before any re-query does (`CheckRules`
|
|
58
|
+
# re-runs `type_of` over the same nodes, and a call-site-driven body re-type may visit a node a
|
|
59
|
+
# second time with a different receiver), so pinning the first decision keeps a file's record
|
|
60
|
+
# independent of how many times the node is visited — which is what makes pooled and sequential
|
|
61
|
+
# runs agree.
|
|
62
|
+
def record(node, call_record)
|
|
63
|
+
@calls[node] = call_record unless @calls.key?(node)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether this node's decision is already pinned. Asked BEFORE the record is built, because the
|
|
67
|
+
# re-queries below are roughly half of all recording calls on a Rails app and building a record
|
|
68
|
+
# only to drop it is the whole of that half's cost.
|
|
69
|
+
def recorded?(node)
|
|
70
|
+
@calls.key?(node)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mark_unresolved(node)
|
|
74
|
+
existing = @calls[node]
|
|
75
|
+
@calls[node] = existing.with(resolved: false) if existing
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Module-level activation count so {active?} is a plain integer read (GVL-atomic) rather than a
|
|
80
|
+
# `Thread.current` hash lookup.
|
|
81
|
+
@active_count = 0
|
|
82
|
+
@mutex = Mutex.new
|
|
83
|
+
|
|
84
|
+
module_function
|
|
85
|
+
|
|
86
|
+
# Runs `block` with collection active for `path` and returns the resulting {FileCollection}. Nests
|
|
87
|
+
# safely and restores the previous accumulator on exit.
|
|
88
|
+
#
|
|
89
|
+
# `attribution` is the project's `effects.attribution:` table (#385), carried on the window rather
|
|
90
|
+
# than read from a global: a worker process and the parent must scan under the same table, and the
|
|
91
|
+
# only thing that knows it is the configuration the run was built from. `envelopes` is the
|
|
92
|
+
# {EnvelopeIndex} of #386, carried the same way and for the same reason — the declared lane a call
|
|
93
|
+
# site imports must not depend on which process typed the file.
|
|
94
|
+
def collect_for(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
95
|
+
plugin_facts: PluginFacts.empty)
|
|
96
|
+
previous = Thread.current[KEY]
|
|
97
|
+
accumulator = Accumulator.new(path.to_s, attribution: attribution, envelopes: envelopes,
|
|
98
|
+
plugin_facts: plugin_facts)
|
|
99
|
+
Thread.current[KEY] = accumulator
|
|
100
|
+
@mutex.synchronize { @active_count += 1 }
|
|
101
|
+
yield
|
|
102
|
+
build(accumulator)
|
|
103
|
+
ensure
|
|
104
|
+
Thread.current[KEY] = previous
|
|
105
|
+
@mutex.synchronize { @active_count -= 1 }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def active?
|
|
109
|
+
@active_count.positive?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The parsed root of the file being analyzed. Recorded by the analysis body once the parse is known
|
|
113
|
+
# to have succeeded, so the scan runs over exactly what the typer saw.
|
|
114
|
+
def record_root(root)
|
|
115
|
+
accumulator = Thread.current[KEY]
|
|
116
|
+
accumulator.root = root if accumulator && accumulator.root.nil?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# One call site's dispatch decision. `receiver` is the type the typer computed for the receiver and
|
|
120
|
+
# `scope` the scope it computed it in; nothing here asks either for more.
|
|
121
|
+
#
|
|
122
|
+
# The `scope.source_path` guard matters: inter-procedural inference re-types a *callee's* body while
|
|
123
|
+
# the caller's file is being analyzed, and those nodes belong to the callee's own file collection.
|
|
124
|
+
def record_call(node, receiver, scope)
|
|
125
|
+
accumulator = Thread.current[KEY]
|
|
126
|
+
return if accumulator.nil? || accumulator.path != scope.source_path
|
|
127
|
+
# First write wins, so a re-query has nothing to add — and asking here rather than inside
|
|
128
|
+
# {Accumulator#record} is what stops it paying for the `Data` and the origin lookup below.
|
|
129
|
+
return if accumulator.recorded?(node)
|
|
130
|
+
|
|
131
|
+
dynamic = receiver.is_a?(Type::Dynamic)
|
|
132
|
+
class_name, kind = descriptor_for(receiver)
|
|
133
|
+
accumulator.record(
|
|
134
|
+
node,
|
|
135
|
+
CallRecord.new(
|
|
136
|
+
receiver_class: class_name, kind: kind, dynamic: dynamic,
|
|
137
|
+
cause: dynamic ? Inference::OriginLookup.origin_for(scope, node.receiver)&.to_s : nil,
|
|
138
|
+
resolved: true
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The typer's fallback verdict: no tier resolved this call.
|
|
144
|
+
def record_unresolved(node, source_path)
|
|
145
|
+
accumulator = Thread.current[KEY]
|
|
146
|
+
return if accumulator.nil? || accumulator.path != source_path
|
|
147
|
+
|
|
148
|
+
accumulator.mark_unresolved(node)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The class a receiver type projects to, as `[class_name, kind]`. A deliberately small mirror of the
|
|
152
|
+
# dispatcher's own receiver descriptor: enough shapes that a fresh `[]` reads as `Array` and a
|
|
153
|
+
# constant receiver as its singleton, and nothing more. An unmapped type simply has no class name,
|
|
154
|
+
# which costs an edge rather than producing a wrong one.
|
|
155
|
+
def descriptor_for(receiver)
|
|
156
|
+
case receiver
|
|
157
|
+
when Type::Nominal then [receiver.class_name, :instance]
|
|
158
|
+
when Type::Singleton then [receiver.class_name, :singleton]
|
|
159
|
+
when Type::Tuple then ["Array", :instance]
|
|
160
|
+
when Type::HashShape then ["Hash", :instance]
|
|
161
|
+
when Type::Constant then [receiver.value.class.name, :instance]
|
|
162
|
+
when Type::Dynamic then descriptor_for(receiver.static_facet)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Fail-soft (WD13): the scan raising drops this file's summaries and never reaches `rigor check`.
|
|
167
|
+
def build(accumulator)
|
|
168
|
+
return FileCollection.empty(accumulator.path) if accumulator.root.nil?
|
|
169
|
+
|
|
170
|
+
Scanner.scan(
|
|
171
|
+
root: accumulator.root, path: accumulator.path, calls: accumulator.calls,
|
|
172
|
+
attribution: accumulator.attribution, envelopes: accumulator.envelopes,
|
|
173
|
+
plugin_facts: accumulator.plugin_facts
|
|
174
|
+
)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
FileCollection.new(path: accumulator.path, failed: true)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private_class_method :build, :descriptor_for
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "envelope"
|
|
4
|
+
require_relative "label_set"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Envelopes by **convention** — the `effects.envelopes:` block of `.rigor.yml` (ADR-103 WD5 (2);
|
|
10
|
+
# design note § 6.2).
|
|
11
|
+
#
|
|
12
|
+
# ```yaml
|
|
13
|
+
# effects:
|
|
14
|
+
# envelopes:
|
|
15
|
+
# - match: "app/presenters/**/*.rb" # File.fnmatch, project-relative — the ADR-28 shape
|
|
16
|
+
# effect: [] # the empty envelope: `pure`
|
|
17
|
+
# - namespace: "Policies::*"
|
|
18
|
+
# effect: [mutate.local]
|
|
19
|
+
# ```
|
|
20
|
+
#
|
|
21
|
+
# This is the surface that pays on day one for a project that writes no RBS: one stanza bounds a whole
|
|
22
|
+
# architectural layer. An entry attaches an envelope to every method of every class it selects and
|
|
23
|
+
# **distributes exactly as a class-level annotation does** — reopenings and synthesised `attr_*` /
|
|
24
|
+
# `define_method` members included, never subclasses (a subclass matches only if it matches on its own
|
|
25
|
+
# account).
|
|
26
|
+
#
|
|
27
|
+
# ## Selection
|
|
28
|
+
#
|
|
29
|
+
# - `match:` selects by the class's **defining file**. A class matches when *any* file that defines a
|
|
30
|
+
# method of it matches the glob — a class opened in `app/presenters/user.rb` and reopened in
|
|
31
|
+
# `lib/patch.rb` is a presenter. `File.fnmatch?` with `FNM_PATHNAME`, project-relative, so `**` is
|
|
32
|
+
# the only way across a directory boundary: the `unused --entry-point` and
|
|
33
|
+
# `effects.snapshot.reach:` semantics, spelled once ({.path_match?}).
|
|
34
|
+
# - `namespace:` selects by the class's **fully-qualified name**, segment by segment ({.namespace_match?}).
|
|
35
|
+
#
|
|
36
|
+
# ## Precedence
|
|
37
|
+
#
|
|
38
|
+
# Nearest wins, and configuration is the furthest thing from the method:
|
|
39
|
+
#
|
|
40
|
+
# per-method annotation > class-level annotation > config entry
|
|
41
|
+
#
|
|
42
|
+
# Among config entries, the **first** matching entry in file order wins — a list is read top to
|
|
43
|
+
# bottom, and a later entry never silently overrides one an author put above it. There is no merging:
|
|
44
|
+
# one method has at most one envelope, from exactly one source.
|
|
45
|
+
module ConfigEnvelopes
|
|
46
|
+
# What `location` carries for a configured envelope. Not a `path:line` — the loader cannot say which
|
|
47
|
+
# line the entry was written on — so it names the key path instead, which is what a reader greps for.
|
|
48
|
+
CONFIG_PATH = ".rigor.yml"
|
|
49
|
+
|
|
50
|
+
NO_ENVELOPES = {}.freeze
|
|
51
|
+
private_constant :NO_ENVELOPES
|
|
52
|
+
|
|
53
|
+
# One entry, resolved against the vocabulary.
|
|
54
|
+
#
|
|
55
|
+
# `bound` is {LabelSet::TOP} when some member of `effect:` is unrecognised — the fail-open rule, the
|
|
56
|
+
# same degradation an annotation carrying an unknown label takes — and `unknown_labels` is what
|
|
57
|
+
# `effect.unknown-label` reports off.
|
|
58
|
+
Entry = Data.define(:index, :match, :namespace, :bound, :labels, :unknown_labels) do
|
|
59
|
+
# `.rigor.yml effects.envelopes[2]` — how the `effect.envelope-exceeded` message names the source.
|
|
60
|
+
def location = "#{CONFIG_PATH} effects.envelopes[#{index}]"
|
|
61
|
+
|
|
62
|
+
# The bound quoted back the way the author wrote it, in the config's own spelling.
|
|
63
|
+
def spelling = "effect: [#{labels.join(', ')}]"
|
|
64
|
+
|
|
65
|
+
def top? = bound.top?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module_function
|
|
69
|
+
|
|
70
|
+
# Resolves `Configuration#effects_envelopes` against a registry.
|
|
71
|
+
#
|
|
72
|
+
# @param entries [Array<Hash>] the loaded, shape-validated entries
|
|
73
|
+
# @param registry [Registry] the vocabulary, project extensions included
|
|
74
|
+
# @return [Array<Entry>]
|
|
75
|
+
def build(entries:, registry:)
|
|
76
|
+
entries.each_with_index.map do |entry, index|
|
|
77
|
+
labels = Array(entry["effect"]).map(&:to_s)
|
|
78
|
+
unknown = labels.reject { |label| registry.known?(label) }
|
|
79
|
+
Entry.new(
|
|
80
|
+
index: index, match: entry["match"], namespace: entry["namespace"],
|
|
81
|
+
bound: unknown.empty? ? LabelSet.new(labels) : LabelSet::TOP,
|
|
82
|
+
labels: labels.freeze, unknown_labels: unknown.freeze
|
|
83
|
+
)
|
|
84
|
+
end.freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The class-level envelopes the entries put on a project.
|
|
88
|
+
#
|
|
89
|
+
# @param entries [Array<Entry>]
|
|
90
|
+
# @param class_names [Enumerable<String>] every class the run collected units for
|
|
91
|
+
# @param sources [Hash{String => Array<String>}] `Runner#effect_sources` — `{method key => [path]}`
|
|
92
|
+
# @param project_root [String] what `sources` paths are relativised against
|
|
93
|
+
# @return [Hash{String => Envelope}] one envelope per selected class, keyed by class name
|
|
94
|
+
def for_classes(entries:, class_names:, sources: {}, project_root: Dir.pwd)
|
|
95
|
+
return NO_ENVELOPES if entries.empty?
|
|
96
|
+
|
|
97
|
+
files = files_by_class(sources, project_root)
|
|
98
|
+
class_names.each_with_object({}) do |class_name, out|
|
|
99
|
+
entry = entries.find { |candidate| selects?(candidate, class_name, files[class_name]) }
|
|
100
|
+
next if entry.nil?
|
|
101
|
+
|
|
102
|
+
out[class_name] = envelope_for(entry, class_name)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The {Envelope} an entry puts on one class. Public because {EnvelopeIndex} resolves the same
|
|
107
|
+
# entries per *call site* rather than per project class ({.for_classes}'s shape), and the two
|
|
108
|
+
# must build the identical value: a bound that read differently at a call site and at the `def`
|
|
109
|
+
# would make the `≤` lane disagree with the check that enforces it.
|
|
110
|
+
def envelope_for(entry, class_name)
|
|
111
|
+
Envelope.build(
|
|
112
|
+
owner_key: class_name, bound: entry.bound, source: Envelope::CONFIG_SOURCE,
|
|
113
|
+
location: entry.location, spelling: entry.spelling,
|
|
114
|
+
unknown_labels: entry.unknown_labels, declared_labels: entry.labels
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def selects?(entry, class_name, paths)
|
|
119
|
+
return namespace_match?(entry.namespace, class_name) if entry.namespace
|
|
120
|
+
|
|
121
|
+
Array(paths).any? { |path| path_match?(entry.match, path) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `File.fnmatch?` with `FNM_PATHNAME` over a project-relative path — the ADR-28 `path_glob` shape
|
|
125
|
+
# and the `unused --entry-point` one. `FNM_PATHNAME` is what makes `app/*/x.rb` stop at one
|
|
126
|
+
# directory and `**` the only way past it.
|
|
127
|
+
def path_match?(glob, path)
|
|
128
|
+
File.fnmatch?(glob, path, File::FNM_PATHNAME)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A constant-path glob, matched **segment by segment** over the `::`-separated FQN:
|
|
132
|
+
#
|
|
133
|
+
# - a literal segment matches itself, and `*` inside one matches any run of characters *within* that
|
|
134
|
+
# segment (`Api::V*` matches `Api::V2`);
|
|
135
|
+
# - `*` alone matches exactly one segment — `Presenters::*` matches `Presenters::User` and NOT
|
|
136
|
+
# `Presenters::Admin::User`, nor bare `Presenters`;
|
|
137
|
+
# - `**` matches one or more consecutive segments — `Presenters::**` matches both `Presenters::User`
|
|
138
|
+
# and `Presenters::Admin::User`, and still not bare `Presenters`.
|
|
139
|
+
#
|
|
140
|
+
# Deliberately not `File.fnmatch` over a `/`-substituted name: the semantics above are the ones the
|
|
141
|
+
# documentation states, and borrowing a path matcher would make them depend on how one library
|
|
142
|
+
# happens to treat a trailing `**`.
|
|
143
|
+
def namespace_match?(glob, class_name)
|
|
144
|
+
match_segments?(glob.to_s.split("::"), class_name.to_s.split("::"))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def match_segments?(pattern, name)
|
|
148
|
+
return name.empty? if pattern.empty?
|
|
149
|
+
|
|
150
|
+
head, *rest = pattern
|
|
151
|
+
return match_deep?(rest, name) if head == "**"
|
|
152
|
+
return false if name.empty?
|
|
153
|
+
return false unless File.fnmatch?(head, name.first)
|
|
154
|
+
|
|
155
|
+
match_segments?(rest, name.drop(1))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# `**` consumes at least one segment, then the remaining pattern must match what is left. Bounded by
|
|
159
|
+
# the name's own depth, so the recursion is a handful of frames on any real constant path.
|
|
160
|
+
def match_deep?(rest, name)
|
|
161
|
+
(1..name.length).any? { |taken| match_segments?(rest, name.drop(taken)) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `{class name => [project-relative path]}`, from the per-unit source table. Sorted and de-duplicated
|
|
165
|
+
# so a glob decision does not depend on Hash order.
|
|
166
|
+
def files_by_class(sources, project_root)
|
|
167
|
+
root = "#{File.absolute_path(project_root.to_s).chomp('/')}/"
|
|
168
|
+
sources.each_with_object({}) do |(key, paths), out|
|
|
169
|
+
owner = MethodKey.owner(key)
|
|
170
|
+
next if owner.nil?
|
|
171
|
+
|
|
172
|
+
bucket = (out[owner] ||= [])
|
|
173
|
+
Array(paths).each { |path| bucket << relativize(path, root) }
|
|
174
|
+
end.each_value(&:uniq!)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def relativize(path, root)
|
|
178
|
+
absolute = File.absolute_path(path.to_s)
|
|
179
|
+
absolute.start_with?(root) ? absolute[root.length..] : path.to_s
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private_class_method :selects?, :match_segments?, :match_deep?, :files_by_class, :relativize
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|