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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "summary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# What one analyzed file contributes to the project's effect graph (ADR-103 WD12).
|
|
8
|
+
#
|
|
9
|
+
# The collector produces one of these per file and the runner marshals it back from a fork-pool worker
|
|
10
|
+
# with the file's diagnostics, so every field is Marshal-clean: frozen Hashes of Strings, {Summary}
|
|
11
|
+
# values, and `Data` edges. Nothing here holds a Prism node, a `Scope` or an environment.
|
|
12
|
+
#
|
|
13
|
+
# It carries four tables:
|
|
14
|
+
#
|
|
15
|
+
# - {#summaries} — the **direct** summary of each method the file defines, keyed `Class#m` / `Class.m` /
|
|
16
|
+
# `<toplevel>#m` (WD14). A reopening in another file contributes the same key and the two join.
|
|
17
|
+
# - {#edges} — per method key, the calls that must be resolved against the *project* before they become
|
|
18
|
+
# graph edges. Resolution is deferred to the propagator because a file cannot see the whole class
|
|
19
|
+
# graph; the collector only records what the typer decided about the receiver.
|
|
20
|
+
# - {#superclasses} / {#includes} — the ancestry the propagator needs to resolve an edge through
|
|
21
|
+
# inherited methods and to find every project-known override of a call's target (the closed-world
|
|
22
|
+
# join of WD4).
|
|
23
|
+
#
|
|
24
|
+
# Merging is associative and commutative in every table, so folding a run's files in pool-completion
|
|
25
|
+
# order yields exactly the table sequential analysis yields.
|
|
26
|
+
class FileCollection
|
|
27
|
+
# One recorded call, before the project can say which definition it reaches.
|
|
28
|
+
#
|
|
29
|
+
# `receiver_class` is the class name the typer had at the call site (nil when it had none — a Dynamic
|
|
30
|
+
# receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
|
|
31
|
+
# `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
|
|
32
|
+
# whose failure to resolve is an `unresolved-self-call` taint rather than silence.
|
|
33
|
+
Edge = Data.define(:receiver_class, :kind, :selector, :self_call)
|
|
34
|
+
|
|
35
|
+
NO_TABLE = {}.freeze
|
|
36
|
+
private_constant :NO_TABLE
|
|
37
|
+
|
|
38
|
+
# The collection a file with nothing to say contributes — a parse failure, a file of constants, or a
|
|
39
|
+
# run where the collector was never activated.
|
|
40
|
+
def self.empty(path = nil)
|
|
41
|
+
new(path: path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :path, :summaries, :edges, :superclasses, :includes
|
|
45
|
+
|
|
46
|
+
def initialize(path: nil, summaries: NO_TABLE, edges: NO_TABLE,
|
|
47
|
+
superclasses: NO_TABLE, includes: NO_TABLE, failed: false)
|
|
48
|
+
@path = path
|
|
49
|
+
@summaries = freeze_table(summaries)
|
|
50
|
+
@edges = freeze_edges(edges)
|
|
51
|
+
@superclasses = freeze_table(superclasses)
|
|
52
|
+
@includes = freeze_table(includes)
|
|
53
|
+
@failed = failed ? true : false
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether the collector gave up on this file entirely (the fail-soft path). Its methods contribute
|
|
58
|
+
# nothing rather than contributing a wrong summary; `rigor check` is unaffected either way.
|
|
59
|
+
def failed?
|
|
60
|
+
@failed
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def empty?
|
|
64
|
+
@summaries.empty? && @edges.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Folds another collection into this one. Summaries join per key, edge lists union, ancestry merges.
|
|
68
|
+
#
|
|
69
|
+
# **Fold a whole run with {merge_all}, not with this in a `reduce`.** Every call here rebuilds and
|
|
70
|
+
# re-freezes the accumulated tables, so folding a run one file at a time costs O(files × methods) —
|
|
71
|
+
# it was 5.1 s of mastodon's 6.4 s collection overhead and the whole of gitlab's superlinear one
|
|
72
|
+
# (`docs/notes/20260817-effect-collection-perf.md`). This stays for a two-collection merge, which is
|
|
73
|
+
# what its cost model fits.
|
|
74
|
+
def merge(other)
|
|
75
|
+
return self if other.empty? && !other.failed?
|
|
76
|
+
|
|
77
|
+
self.class.merge_all([self, other])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Folds a run's collections in one linear pass: each key's summaries join once, each table is built
|
|
81
|
+
# once, and the frozen result is constructed once at the end. Order-independent in every table
|
|
82
|
+
# except `superclasses`, where a later collection's spelling wins exactly as a chain of {merge}
|
|
83
|
+
# calls would leave it, so a path-sorted fold is reproducible.
|
|
84
|
+
#
|
|
85
|
+
# A collection that is {empty?} and not {failed?} contributes nothing, which is {merge}'s own
|
|
86
|
+
# short-circuit spelled once: a file with no methods and no calls has no summary to fold, and its
|
|
87
|
+
# ancestry has no unit to attach to.
|
|
88
|
+
def self.merge_all(collections)
|
|
89
|
+
summaries = {}
|
|
90
|
+
edges = {}
|
|
91
|
+
superclasses = {}
|
|
92
|
+
includes = {}
|
|
93
|
+
failed = false
|
|
94
|
+
|
|
95
|
+
collections.each do |collection|
|
|
96
|
+
failed ||= collection.failed?
|
|
97
|
+
next if collection.empty?
|
|
98
|
+
|
|
99
|
+
fold_summaries(summaries, collection.summaries)
|
|
100
|
+
fold_lists(edges, collection.edges)
|
|
101
|
+
superclasses.update(collection.superclasses)
|
|
102
|
+
fold_lists(includes, collection.includes)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
includes.each_value(&:uniq!)
|
|
106
|
+
new(path: nil, summaries: summaries, edges: edges,
|
|
107
|
+
superclasses: superclasses, includes: includes, failed: failed)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.fold_summaries(into, table)
|
|
111
|
+
table.each do |key, summary|
|
|
112
|
+
existing = into[key]
|
|
113
|
+
into[key] = existing ? existing.join(summary) : summary
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :fold_summaries
|
|
117
|
+
|
|
118
|
+
# De-duplication is deferred to the single pass at the end of {merge_all} — `freeze_edges` uniqs
|
|
119
|
+
# and sorts anyway, and uniqing per file is what made the fold quadratic.
|
|
120
|
+
def self.fold_lists(into, table)
|
|
121
|
+
table.each do |key, list|
|
|
122
|
+
existing = into[key]
|
|
123
|
+
existing ? existing.concat(list) : into[key] = list.dup
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
private_class_method :fold_lists
|
|
127
|
+
|
|
128
|
+
def ==(other)
|
|
129
|
+
other.is_a?(FileCollection) && other.summaries == @summaries && other.edges == @edges &&
|
|
130
|
+
other.superclasses == @superclasses && other.includes == @includes && other.failed? == @failed
|
|
131
|
+
end
|
|
132
|
+
alias eql? ==
|
|
133
|
+
|
|
134
|
+
def hash
|
|
135
|
+
[self.class, @summaries, @edges, @superclasses, @includes, @failed].hash
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def freeze_table(table)
|
|
141
|
+
return NO_TABLE if table.empty?
|
|
142
|
+
|
|
143
|
+
table.transform_values { |value| value.is_a?(Array) ? value.freeze : value }.freeze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
|
|
147
|
+
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` is
|
|
148
|
+
# in it because two edges can otherwise agree on every other field, and `sort_by` is not stable.
|
|
149
|
+
def freeze_edges(table)
|
|
150
|
+
return NO_TABLE if table.empty?
|
|
151
|
+
|
|
152
|
+
table.transform_values do |list|
|
|
153
|
+
sorted = list.uniq
|
|
154
|
+
sorted.sort_by! { |edge| [edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0] }
|
|
155
|
+
sorted.freeze
|
|
156
|
+
end.freeze
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_collection"
|
|
4
|
+
require_relative "label_set"
|
|
5
|
+
require_relative "origin"
|
|
6
|
+
require_relative "summary"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# The framework **edges** a plugin declared, materialised as effect units on the framework class itself
|
|
11
|
+
# (ADR-103 WD10; design note § 11.2 "Framework edges"; #387).
|
|
12
|
+
#
|
|
13
|
+
# `user.save` runs `User`'s `before_save :normalize`; `WelcomeJob.perform_now` runs
|
|
14
|
+
# `WelcomeJob#perform`; `UserMailer.welcome(u)` runs `UserMailer#welcome`. Each is real, synchronous,
|
|
15
|
+
# in-process control flow that the *syntax* does not contain, so without the plugin the caller's
|
|
16
|
+
# summary stops one hop short of the code that actually runs.
|
|
17
|
+
#
|
|
18
|
+
# ## Why units on the class rather than edges at the call site
|
|
19
|
+
#
|
|
20
|
+
# The call site is in another file. `app/controllers/users_controller.rb` writes `@user.save` and knows
|
|
21
|
+
# nothing about `User`'s callbacks — those are in `app/models/user.rb`, and a per-file collection window
|
|
22
|
+
# sees one file at a time. Synthesising `User#save` **where `User` is declared**, with edges to the
|
|
23
|
+
# callback methods, moves the framework knowledge to the file that has it and leaves the call site an
|
|
24
|
+
# ordinary edge. The propagator then resolves `(User, :instance, "save")` to the synthetic unit exactly
|
|
25
|
+
# as it resolves any other, ancestry and closed-world override join included — no second mechanism, no
|
|
26
|
+
# cross-file harvest, and a `save` on a subclass picks up that subclass's own callbacks for free.
|
|
27
|
+
#
|
|
28
|
+
# A project that defines `User#save` itself simply joins with the synthetic unit, which is the honest
|
|
29
|
+
# reading: an override still runs the callbacks unless it skips them.
|
|
30
|
+
#
|
|
31
|
+
# ## What is deliberately not here
|
|
32
|
+
#
|
|
33
|
+
# `perform_later` → `perform`. The deferred body runs in another process on another stack, and ADR-103
|
|
34
|
+
# WD4 fixes attribution to the code rather than to the clock: the enqueue is the effect, and the job's
|
|
35
|
+
# body belongs to the job's own entry point. {Plugin::EffectEdge::TARGETS} has no spelling for it.
|
|
36
|
+
module FrameworkUnits
|
|
37
|
+
IO_DB_READ = LabelSet.new(["io.db.read"]).freeze
|
|
38
|
+
private_constant :IO_DB_READ
|
|
39
|
+
|
|
40
|
+
# ActiveRecord's class-body callback macros, grouped by which persistence selectors run them. The
|
|
41
|
+
# grouping is coarse on purpose: an effect summary is an **upper bound**, and the cost of attributing
|
|
42
|
+
# a `before_create` to `save` (which does run it, when the record is new) is nothing, while the cost of
|
|
43
|
+
# a group fine enough to be wrong somewhere is a missing effect.
|
|
44
|
+
VALIDATION_MACROS = %w[validate before_validation after_validation].freeze
|
|
45
|
+
|
|
46
|
+
SAVE_MACROS = %w[
|
|
47
|
+
before_save around_save after_save before_create around_create after_create
|
|
48
|
+
before_update around_update after_update after_touch
|
|
49
|
+
before_commit after_commit after_rollback
|
|
50
|
+
after_create_commit after_update_commit after_save_commit
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
DESTROY_MACROS = %w[
|
|
54
|
+
before_destroy around_destroy after_destroy after_destroy_commit after_commit after_rollback
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Macros whose literal symbol arguments name methods on the same class. Everything the strategy reads.
|
|
58
|
+
CALLBACK_MACROS = (VALIDATION_MACROS + SAVE_MACROS + DESTROY_MACROS).uniq.freeze
|
|
59
|
+
|
|
60
|
+
# The uniqueness validator is a `SELECT` before the write — the one validation whose effect a reviewer
|
|
61
|
+
# is entitled to see in a summary. Both spellings Rails accepts.
|
|
62
|
+
UNIQUENESS_MACRO = "validates_uniqueness_of"
|
|
63
|
+
VALIDATES_MACRO = "validates"
|
|
64
|
+
UNIQUENESS_OPTION = "uniqueness"
|
|
65
|
+
|
|
66
|
+
# Which selectors run which group. `create` / `create!` are the singleton twins of `save`.
|
|
67
|
+
SAVE_TRIGGERS = %w[save save! update update! update_attribute touch increment! decrement!].freeze
|
|
68
|
+
DESTROY_TRIGGERS = %w[destroy destroy! delete].freeze
|
|
69
|
+
VALIDATION_TRIGGERS = %w[valid? invalid? validate!].freeze
|
|
70
|
+
SINGLETON_SAVE_TRIGGERS = %w[create create!].freeze
|
|
71
|
+
|
|
72
|
+
# `initialize` is not a mailer action, and neither is a method a plugin would want double-counted.
|
|
73
|
+
NON_ACTION_METHODS = %w[initialize].to_set.freeze
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
# Every synthetic unit `class_name` earns, as `[key, Summary, edges]` triples.
|
|
78
|
+
#
|
|
79
|
+
# @param class_name [String]
|
|
80
|
+
# @param instance_methods [Array<String>] the instance methods the class body defines, in source order
|
|
81
|
+
# @param macros [Hash{String=>Array<String>}] receiver-less class-body calls to their literal symbol
|
|
82
|
+
# arguments, as the scanner harvested them
|
|
83
|
+
# @param uniqueness [Boolean] whether the class body declares a uniqueness validator
|
|
84
|
+
# @param plugin_facts [PluginFacts]
|
|
85
|
+
def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:)
|
|
86
|
+
units = []
|
|
87
|
+
plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
|
|
88
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
89
|
+
|
|
90
|
+
units.concat(active_record_units(class_name, macros, uniqueness))
|
|
91
|
+
end
|
|
92
|
+
plugin_facts.edges_for(:perform_now).each do |edge|
|
|
93
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
94
|
+
|
|
95
|
+
# `selector` (the edge's `method:`) names the synthesised selector, defaulting to
|
|
96
|
+
# `perform_now`. The one other value it
|
|
97
|
+
# ever takes is `perform_later` — and ONLY from a plugin that has read the project's own
|
|
98
|
+
# `queue_adapter = :inline`, where Rails really does run the job on the caller's stack. That is a
|
|
99
|
+
# project fact narrowing a transport, not a general edge (ADR-103 WD4).
|
|
100
|
+
units << unit("#{class_name}.#{edge.selector || :perform_now}", [edge_to(class_name, "perform")])
|
|
101
|
+
end
|
|
102
|
+
plugin_facts.edges_for(:mailer_body).each do |edge|
|
|
103
|
+
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
104
|
+
|
|
105
|
+
units.concat(mailer_units(class_name, instance_methods))
|
|
106
|
+
end
|
|
107
|
+
units
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
|
|
111
|
+
# no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
|
|
112
|
+
# every model in the project and say nothing.
|
|
113
|
+
def active_record_units(class_name, macros, uniqueness)
|
|
114
|
+
validation = callbacks(macros, VALIDATION_MACROS)
|
|
115
|
+
save = validation + callbacks(macros, SAVE_MACROS)
|
|
116
|
+
destroy = callbacks(macros, DESTROY_MACROS)
|
|
117
|
+
read = uniqueness ? uniqueness_summary(class_name) : nil
|
|
118
|
+
|
|
119
|
+
units = []
|
|
120
|
+
units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false))
|
|
121
|
+
units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true))
|
|
122
|
+
units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false))
|
|
123
|
+
units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false))
|
|
124
|
+
units
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ActionMailer's class-method-to-instance mapping: `UserMailer.welcome(u)` instantiates the mailer and
|
|
128
|
+
# runs `#welcome`. One synthetic singleton twin per instance method the mailer defines.
|
|
129
|
+
def mailer_units(class_name, instance_methods)
|
|
130
|
+
instance_methods.reject { |name| NON_ACTION_METHODS.include?(name) }.uniq.map do |name|
|
|
131
|
+
unit("#{class_name}.#{name}", [edge_to(class_name, name)])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def triggers(class_name, selectors, targets, read, singleton:)
|
|
136
|
+
return [] if targets.empty? && read.nil?
|
|
137
|
+
|
|
138
|
+
edges = targets.map { |target| edge_to(class_name, target) }
|
|
139
|
+
selectors.map { |selector| unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges, read) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def callbacks(macros, names)
|
|
143
|
+
names.flat_map { |name| macros[name] || [] }.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The uniqueness validator's own query. It rides the DECLARED lane with no taint, exactly as every
|
|
147
|
+
# other first-party plugin contribution does (ADR-103 WD6): the plugin read the app's own
|
|
148
|
+
# `validates … uniqueness: true` and knows what Rails does with it, but the analyzer did not read a
|
|
149
|
+
# body, so this is a trusted claim rather than a proof.
|
|
150
|
+
def uniqueness_summary(class_name)
|
|
151
|
+
{ Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def unit(key, edges, declared = nil)
|
|
155
|
+
[key, Summary.new(declared_bundles: declared || {}), edges]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def edge_to(class_name, selector)
|
|
159
|
+
FileCollection::Edge.new(receiver_class: class_name, kind: :instance, selector: selector,
|
|
160
|
+
self_call: false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private_class_method :active_record_units, :mailer_units, :triggers, :callbacks, :uniqueness_summary,
|
|
164
|
+
:unit, :edge_to
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../cache/descriptor"
|
|
7
|
+
require_relative "catalog"
|
|
8
|
+
require_relative "registry"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Effects
|
|
12
|
+
# The **effects cache identity** — the second of ADR-103 WD13's "one cache, two identities, one extra
|
|
13
|
+
# slot" (issue #382; the contract is `docs/internal-spec/effect-summaries.md` § Caching).
|
|
14
|
+
#
|
|
15
|
+
# A run has one identity for its diagnostics and one for its effect summaries. The diagnostics identity
|
|
16
|
+
# is today's and is deliberately untouched by this file: collection is observational, so a diagnostics
|
|
17
|
+
# entry computed with effects on is valid for a run with effects off and vice versa. The effects
|
|
18
|
+
# identity is that identity **plus** the three things that change what a summary means without changing
|
|
19
|
+
# a single analyzed byte:
|
|
20
|
+
#
|
|
21
|
+
# - the **vocabulary version** ({Registry#vocabulary_version}) — a rename or a removal re-reads every
|
|
22
|
+
# persisted label;
|
|
23
|
+
# - the **catalogue identity** ({Catalog#identity}, schema + a digest of `data/effects/core.yml`) — an
|
|
24
|
+
# audited row moving from `io` to `io.fs.write` re-colours summaries the analyzed source never moved;
|
|
25
|
+
# - the **`effects:` block digest** — the same value the snapshot header carries, so a `tolerated:` or
|
|
26
|
+
# `reach:` edit is one visible regeneration event rather than two silently disagreeing digests;
|
|
27
|
+
# - the **plugin fact digest** ({PluginFacts#digest}, #387) — the loaded plugins' labels, attributions,
|
|
28
|
+
# edges and presets. A plugin upgrade that moves `perform_later` from `io` to `io.db.write` re-colours
|
|
29
|
+
# summaries the analyzed source never moved, exactly as a re-audited catalogue row does, and the
|
|
30
|
+
# plugin set is otherwise invisible to a key derived from configuration alone.
|
|
31
|
+
#
|
|
32
|
+
# This module is the ONE place that answers it. {.digest} is the string form (what the ADR-46 snapshot
|
|
33
|
+
# payload carries beside `return_summaries`) and {.descriptor} is the {Cache::Descriptor} form (what the
|
|
34
|
+
# ADR-45 whole-run effects slot is keyed by): the same three inputs, spelled for the two stores.
|
|
35
|
+
#
|
|
36
|
+
# Nothing here is consulted when collection is off — no entry is written, no key is perturbed, and the
|
|
37
|
+
# `effects:` block stays out of `Configuration#to_h` so enabling the feature invalidates no existing
|
|
38
|
+
# project's diagnostics cache.
|
|
39
|
+
module Identity
|
|
40
|
+
# The `configs:` slot the effects identity adds on top of a run's diagnostics key descriptor.
|
|
41
|
+
CONFIG_KEY = "effects.identity"
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# The effects identity as a hex digest — the form a store with no descriptor of its own (the ADR-46
|
|
46
|
+
# incremental snapshot) carries alongside its payload, and compares verbatim on restore.
|
|
47
|
+
#
|
|
48
|
+
# @param configuration [Rigor::Configuration]
|
|
49
|
+
# @param registry [Registry] the vocabulary whose version participates
|
|
50
|
+
# @param catalog [Catalog] the catalogue whose identity participates
|
|
51
|
+
# @return [String] hex SHA-256
|
|
52
|
+
def digest(configuration:, registry: Registry.default, catalog: Catalog.default, plugin_facts: nil)
|
|
53
|
+
Digest::SHA256.hexdigest(
|
|
54
|
+
[
|
|
55
|
+
"vocabulary:#{registry.vocabulary_version}",
|
|
56
|
+
"catalog:#{catalog.identity}",
|
|
57
|
+
"effects:#{config_digest(configuration)}",
|
|
58
|
+
"plugins:#{plugin_facts&.digest || 'none'}"
|
|
59
|
+
].join("\x00")
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The effects identity as a cache KEY descriptor: the run's own diagnostics key descriptor plus one
|
|
64
|
+
# `configs:` entry carrying {.digest}. Composing (rather than rebuilding) is what makes the effects
|
|
65
|
+
# slot inherit every diagnostics-invalidating input — engine version, engine source, RBS libraries,
|
|
66
|
+
# the analyzed-path set — for free, so "the effects identity is the diagnostics identity plus three
|
|
67
|
+
# things" is a property of the code rather than a claim about it.
|
|
68
|
+
#
|
|
69
|
+
# @param base [Cache::Descriptor] the run's diagnostics key descriptor
|
|
70
|
+
# @return [Cache::Descriptor]
|
|
71
|
+
def descriptor(base:, configuration:, registry: Registry.default, catalog: Catalog.default,
|
|
72
|
+
plugin_facts: nil)
|
|
73
|
+
Cache::Descriptor.compose(
|
|
74
|
+
base,
|
|
75
|
+
Cache::Descriptor.new(
|
|
76
|
+
configs: [
|
|
77
|
+
Cache::Descriptor::ConfigEntry.new(
|
|
78
|
+
key: CONFIG_KEY,
|
|
79
|
+
value_hash: digest(configuration: configuration, registry: registry, catalog: catalog,
|
|
80
|
+
plugin_facts: plugin_facts)
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The `effects:` block of `.rigor.yml`, canonicalised (keys sorted at every depth, rendered as JSON)
|
|
88
|
+
# and hashed. {Snapshot.config_digest} is this method — the snapshot header and the cache identity
|
|
89
|
+
# MUST agree, and the cheapest way to guarantee that is for there to be one implementation.
|
|
90
|
+
def config_digest(configuration)
|
|
91
|
+
Digest::SHA256.hexdigest(JSON.generate(canonicalize(configuration.effects || {})))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def canonicalize(value)
|
|
95
|
+
case value
|
|
96
|
+
when Hash then value.map { |key, member| [key.to_s, canonicalize(member)] }.sort_by(&:first).to_h
|
|
97
|
+
when Array then value.map { |member| canonicalize(member) }
|
|
98
|
+
when Symbol then value.to_s
|
|
99
|
+
else value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# The effect-label grammar and the subsumption relation over it (ADR-103 WD1; normative in
|
|
6
|
+
# `docs/type-specification/effect-labels.md`).
|
|
7
|
+
#
|
|
8
|
+
# A label is a dot-path of lowercase segments — `io`, `io.net.http`, `nondet.time`. The relation
|
|
9
|
+
# that matters is **segment-aware prefix subsumption**: `io` admits `io.net.http` and rejects
|
|
10
|
+
# `iota`. Every method here is pure and total; a malformed input is answered, never raised on,
|
|
11
|
+
# so a caller can ask `valid?` and the rest in either order.
|
|
12
|
+
module Label
|
|
13
|
+
# `label = segment { "." segment }`, `segment = [a-z][a-z0-9]*`. Deliberately narrow: no
|
|
14
|
+
# underscores, no hyphens, no uppercase, no empty segment, no trailing dot. The same grammar
|
|
15
|
+
# is the RFC's `label` production, so a label spells identically in Steins and Rigor.
|
|
16
|
+
PATTERN = /\A[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*\z/
|
|
17
|
+
|
|
18
|
+
SEPARATOR = "."
|
|
19
|
+
private_constant :SEPARATOR
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Whether `str` is a well-formed label. Anything that is not a String is not.
|
|
24
|
+
def valid?(str)
|
|
25
|
+
str.is_a?(String) && PATTERN.match?(str)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The label's segments, outermost first: `"io.net.http"` -> `["io", "net", "http"]`.
|
|
29
|
+
# A malformed label yields an empty array rather than a partial parse.
|
|
30
|
+
def segments(label)
|
|
31
|
+
return [].freeze unless valid?(label)
|
|
32
|
+
|
|
33
|
+
label.split(SEPARATOR).freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether `bound` admits `label` under segment-aware prefix subsumption. A label subsumes
|
|
37
|
+
# itself; `io` subsumes `io.net.http`; `io` does NOT subsume `iota`, because the match is on
|
|
38
|
+
# segment boundaries and not on characters.
|
|
39
|
+
def subsumes?(bound, label)
|
|
40
|
+
return false unless valid?(bound) && valid?(label)
|
|
41
|
+
return true if bound == label
|
|
42
|
+
|
|
43
|
+
label.start_with?("#{bound}#{SEPARATOR}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The label one segment shallower, or `nil` for a root (and for a malformed label).
|
|
47
|
+
def parent(label)
|
|
48
|
+
return nil unless valid?(label)
|
|
49
|
+
|
|
50
|
+
index = label.rindex(SEPARATOR)
|
|
51
|
+
return nil unless index
|
|
52
|
+
|
|
53
|
+
label[0, index]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The label's proper ancestors, outermost first and excluding the label itself:
|
|
57
|
+
# `"io.net.http"` -> `["io", "io.net"]`. A root has no ancestors.
|
|
58
|
+
def ancestors(label)
|
|
59
|
+
parts = segments(label)
|
|
60
|
+
return [].freeze if parts.length <= 1
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
prefix = nil
|
|
64
|
+
parts[0...-1].each do |segment|
|
|
65
|
+
prefix = prefix ? "#{prefix}#{SEPARATOR}#{segment}" : segment
|
|
66
|
+
result << prefix
|
|
67
|
+
end
|
|
68
|
+
result.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The label's outermost segment — the root whose ownership the registry checks.
|
|
72
|
+
def root(label)
|
|
73
|
+
segments(label).first
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# Whether an unrecognised effect label is evidence of a **typo** rather than of a word that was
|
|
8
|
+
# never meant to be a label at all (ADR-103 WD1; normative in
|
|
9
|
+
# `docs/type-specification/effect-labels.md` § Unknown labels).
|
|
10
|
+
#
|
|
11
|
+
# The degradation an unknown label causes is silent by construction — the tag reads ⊤ and stops
|
|
12
|
+
# bounding anything — so a paired diagnostic is the only thing that keeps the fail-open rule
|
|
13
|
+
# honest. But the diagnostic cannot fire on every unrecognised spelling: a vocabulary is open by
|
|
14
|
+
# design (`effects.labels:`, a plugin's own root), so an unknown word is as likely to be a label
|
|
15
|
+
# this project has not registered yet as it is to be a misspelling. Reporting both would put a
|
|
16
|
+
# finding on correct-by-intent code, which is the direction the false-positive budget is not
|
|
17
|
+
# allowed to run ([ADR-5](../../../docs/adr/5-robustness-principle.md)).
|
|
18
|
+
#
|
|
19
|
+
# Intent is therefore read off four signals, any one of which is enough:
|
|
20
|
+
#
|
|
21
|
+
# 1. **A near miss** — the spelling is within {Registry::SUGGESTION_DISTANCE_CAP} edits of a
|
|
22
|
+
# label the registry knows (`io.bd` against `io.db`).
|
|
23
|
+
# 2. **A known sibling** — another member of the same comma-separated list is recognised, so the
|
|
24
|
+
# list as a whole is demonstrably written in this vocabulary.
|
|
25
|
+
# 3. **A dotted path** — the token carries two or more segments (`io.netw`). Nothing but a label
|
|
26
|
+
# is spelled that way; a project opening its own root writes a bare word first.
|
|
27
|
+
# 4. **A retired spelling** — the registry's `retired:` table names it, so the author wrote a
|
|
28
|
+
# label that WAS correct and a vocabulary bump moved it.
|
|
29
|
+
#
|
|
30
|
+
# A lone far-off word (`%a{rigor:v1:effect database}`) matches none of them and stays silent
|
|
31
|
+
# everywhere. It still degrades the tag to ⊤ — the reading never depends on this module.
|
|
32
|
+
module LabelIntent
|
|
33
|
+
# How many dot-separated segments make a token unmistakably label-shaped (signal 3).
|
|
34
|
+
MULTI_SEGMENT_ARITY = 2
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Whether reporting `token` as an unknown label is justified.
|
|
39
|
+
#
|
|
40
|
+
# @param token [String] the spelling as written.
|
|
41
|
+
# @param registry [Rigor::Effects::Registry, nil] the vocabulary AFTER plugin load; `nil` (no
|
|
42
|
+
# vocabulary at all) makes every token unjudgeable and therefore silent.
|
|
43
|
+
# @param siblings [Array<String>] the other tokens of the same list / the same config value.
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def evident?(token, registry, siblings: [])
|
|
46
|
+
return false if registry.nil?
|
|
47
|
+
return false unless Label.valid?(token)
|
|
48
|
+
return false if registry.known?(token)
|
|
49
|
+
|
|
50
|
+
retired?(token, registry) || near_miss?(token, registry) ||
|
|
51
|
+
multi_segment?(token) || known_sibling?(token, registry, siblings)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def retired?(token, registry)
|
|
55
|
+
!Array(registry.retired(token)).empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def near_miss?(token, registry)
|
|
59
|
+
!registry.suggest(token).nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def multi_segment?(token)
|
|
63
|
+
Label.segments(token).length >= MULTI_SEGMENT_ARITY
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def known_sibling?(token, registry, siblings)
|
|
67
|
+
Array(siblings).any? { |sibling| sibling != token && registry.known?(sibling.to_s) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method :retired?, :near_miss?, :multi_segment?, :known_sibling?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|