active_sanction 1.0.0
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 +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
require "active_sanction/error"
|
|
10
|
+
require "active_sanction/configuration"
|
|
11
|
+
require "active_sanction/http_client/errors"
|
|
12
|
+
require "active_sanction/http_client/response"
|
|
13
|
+
|
|
14
|
+
module ActiveSanction
|
|
15
|
+
# A small GET client over `net/http`, built for one job: pulling published
|
|
16
|
+
# sanctions files off government servers.
|
|
17
|
+
#
|
|
18
|
+
# client = ActiveSanction::HttpClient.new
|
|
19
|
+
# client.get("https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV").body
|
|
20
|
+
# client.download("https://.../SDN_ADVANCED.XML", to: "/tmp/sdn_advanced.xml")
|
|
21
|
+
#
|
|
22
|
+
# No HTTP gem: the stdlib covers every need here, and the gem's zero-runtime-
|
|
23
|
+
# dependency promise is worth more to an application embedding it than the
|
|
24
|
+
# ergonomics of a nicer adapter layer would be.
|
|
25
|
+
#
|
|
26
|
+
# What the endpoints force on us, all verified live:
|
|
27
|
+
#
|
|
28
|
+
# * **OFAC returns 403 to a request with no User-Agent**, so the header is
|
|
29
|
+
# mandatory rather than defaulted-and-forgotten, and a blank one raises
|
|
30
|
+
# before a socket is opened.
|
|
31
|
+
# * **OFAC's download URLs 302 to blob storage**, so following redirects is
|
|
32
|
+
# part of a working GET, not an option a caller might switch on.
|
|
33
|
+
# * **SDN_ADVANCED.XML is 126 MB**, so #download streams to disk. Holding a
|
|
34
|
+
# list that size in a String to write it out again is how a sync job gets
|
|
35
|
+
# itself OOM-killed in a container.
|
|
36
|
+
#
|
|
37
|
+
# Bodies come back as the server sent them, with no transcoding: OFAC's CSV
|
|
38
|
+
# and the UN's XML disagree about encoding, and guessing here would corrupt
|
|
39
|
+
# one of them. Parsers (#14, #15) declare what they expect.
|
|
40
|
+
#
|
|
41
|
+
# @api private
|
|
42
|
+
class HttpClient
|
|
43
|
+
extend T::Sig
|
|
44
|
+
|
|
45
|
+
# 303 is included even though it is defined to change the method, because
|
|
46
|
+
# this client only ever issues GET and so already complies.
|
|
47
|
+
#
|
|
48
|
+
# @api private
|
|
49
|
+
REDIRECT_STATUSES = T.let([301, 302, 303, 307, 308].freeze, T::Array[Integer])
|
|
50
|
+
|
|
51
|
+
# Failures worth trying again. A 4xx is never in here: a 403 for a missing
|
|
52
|
+
# User-Agent or a 404 for a retired URL says the request is wrong, and
|
|
53
|
+
# repeating it wastes the publisher's capacity to make the same point.
|
|
54
|
+
#
|
|
55
|
+
# OpenSSL errors are deliberately absent: a certificate that does not
|
|
56
|
+
# verify will not verify a second later either, and quietly retrying a TLS
|
|
57
|
+
# failure against a government endpoint is not a behaviour worth having.
|
|
58
|
+
# See FATAL_ERRORS, which is where they go instead.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
TRANSIENT_ERRORS = T.let(
|
|
62
|
+
[
|
|
63
|
+
EOFError, IOError, SocketError, Net::HTTPBadResponse, Net::ProtocolError,
|
|
64
|
+
Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET,
|
|
65
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EPIPE, Errno::ETIMEDOUT
|
|
66
|
+
].freeze,
|
|
67
|
+
T::Array[T.class_of(StandardError)]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Failures translated on the first attempt rather than retried. A caller
|
|
71
|
+
# rescuing FetchError should not have to know that this client speaks
|
|
72
|
+
# `net/http`, and it certainly should not have to know that `net/http`
|
|
73
|
+
# speaks OpenSSL -- but a bad certificate is still not worth a second
|
|
74
|
+
# request, so it becomes a ConnectionError that says `retryable?` is false.
|
|
75
|
+
#
|
|
76
|
+
# Guarded because `net/http` loads OpenSSL optionally, and a Ruby built
|
|
77
|
+
# without it can still fetch a list over plain HTTP.
|
|
78
|
+
#
|
|
79
|
+
# @api private
|
|
80
|
+
FATAL_ERRORS = T.let(
|
|
81
|
+
(defined?(::OpenSSL::SSL::SSLError) ? [::OpenSSL::SSL::SSLError] : []).freeze,
|
|
82
|
+
T::Array[T.class_of(StandardError)]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Checked when the client is built, so a blank one raises before a socket
|
|
86
|
+
# is opened rather than earning a 403 from OFAC.
|
|
87
|
+
sig { returns(String) }
|
|
88
|
+
attr_reader :user_agent
|
|
89
|
+
|
|
90
|
+
sig { returns(Numeric) }
|
|
91
|
+
attr_reader :open_timeout
|
|
92
|
+
|
|
93
|
+
sig { returns(Numeric) }
|
|
94
|
+
attr_reader :read_timeout
|
|
95
|
+
|
|
96
|
+
sig { returns(Integer) }
|
|
97
|
+
attr_reader :max_redirects
|
|
98
|
+
|
|
99
|
+
sig { returns(Integer) }
|
|
100
|
+
attr_reader :max_retries
|
|
101
|
+
|
|
102
|
+
sig { returns(Numeric) }
|
|
103
|
+
attr_reader :retry_backoff
|
|
104
|
+
|
|
105
|
+
# Settings default to the global configuration, read at construction, so a
|
|
106
|
+
# client built inside a source adapter honours whatever the host
|
|
107
|
+
# application set at boot without threading a config object through every
|
|
108
|
+
# adapter.
|
|
109
|
+
sig do
|
|
110
|
+
params(user_agent: T.untyped, open_timeout: Numeric, read_timeout: Numeric, max_redirects: Integer,
|
|
111
|
+
max_retries: Integer, retry_backoff: Numeric).void
|
|
112
|
+
end
|
|
113
|
+
def initialize(user_agent: ActiveSanction.config.user_agent,
|
|
114
|
+
open_timeout: ActiveSanction.config.open_timeout,
|
|
115
|
+
read_timeout: ActiveSanction.config.read_timeout,
|
|
116
|
+
max_redirects: ActiveSanction.config.max_redirects,
|
|
117
|
+
max_retries: ActiveSanction.config.max_retries,
|
|
118
|
+
retry_backoff: ActiveSanction.config.retry_backoff)
|
|
119
|
+
@user_agent = T.let(Configuration.user_agent!(user_agent), String)
|
|
120
|
+
@open_timeout = T.let(open_timeout, Numeric)
|
|
121
|
+
@read_timeout = T.let(read_timeout, Numeric)
|
|
122
|
+
@max_redirects = T.let(max_redirects, Integer)
|
|
123
|
+
@max_retries = T.let(max_retries, Integer)
|
|
124
|
+
@retry_backoff = T.let(retry_backoff, Numeric)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Fetches a URL and buffers the body in memory. Right for the index pages
|
|
128
|
+
# and small XML lists; use #download for anything list-sized.
|
|
129
|
+
sig { params(url: T.untyped, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
|
|
130
|
+
def get(url, headers: {})
|
|
131
|
+
fetch(url, headers: headers)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Streams a URL to disk, returning a Response whose body is nil -- the
|
|
135
|
+
# bytes are in the file, and materializing them twice defeats the point.
|
|
136
|
+
#
|
|
137
|
+
# `to:` is a path, or any IO-ish object that responds to #write, which is
|
|
138
|
+
# what the payload cache (#11) passes so it can own its own atomicity.
|
|
139
|
+
# Given a path, the download lands on a sibling `.part` file and is renamed
|
|
140
|
+
# only once the server has answered 2xx, so an interrupted or 404'd fetch
|
|
141
|
+
# never leaves something at the destination that looks like a list.
|
|
142
|
+
sig { params(url: T.untyped, to: T.untyped, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
|
|
143
|
+
def download(url, to:, headers: {})
|
|
144
|
+
return fetch(url, headers: headers, sink: to) if to.respond_to?(:write)
|
|
145
|
+
|
|
146
|
+
stream_to_path(url, to.to_s, headers)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
sig { params(url: T.untyped, path: String, headers: T::Hash[T.untyped, T.untyped]).returns(Response) }
|
|
152
|
+
def stream_to_path(url, path, headers)
|
|
153
|
+
partial = "#{path}.part"
|
|
154
|
+
response = T.let(File.open(partial, "wb") { |file| fetch(url, headers: headers, sink: file) }, Response)
|
|
155
|
+
File.rename(partial, path) if response.success?
|
|
156
|
+
response
|
|
157
|
+
ensure
|
|
158
|
+
# `T.must` because Sorbet reads an `ensure` as reachable before the first
|
|
159
|
+
# assignment in the body; `partial` is that assignment.
|
|
160
|
+
FileUtils.rm_f(T.must(partial))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# One hop at a time: each is retried on its own, so a 500 from the blob
|
|
164
|
+
# store the second hop landed on does not replay the first.
|
|
165
|
+
sig { params(url: T.untyped, headers: T::Hash[T.untyped, T.untyped], sink: T.untyped).returns(Response) }
|
|
166
|
+
def fetch(url, headers:, sink: nil)
|
|
167
|
+
uri = uri!(url)
|
|
168
|
+
request_headers = headers!(headers)
|
|
169
|
+
seen = [uri.to_s]
|
|
170
|
+
redirects = []
|
|
171
|
+
|
|
172
|
+
loop do
|
|
173
|
+
response = with_retries(uri) { perform(uri, request_headers, sink, redirects) }
|
|
174
|
+
return response unless response.redirect? && response["location"]
|
|
175
|
+
|
|
176
|
+
redirects << uri
|
|
177
|
+
raise TooManyRedirects, chain_message(redirects) if redirects.size > max_redirects
|
|
178
|
+
|
|
179
|
+
uri = next_hop(uri, response["location"], seen)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
sig do
|
|
184
|
+
params(uri: URI::Generic, headers: T::Hash[String, String], sink: T.untyped,
|
|
185
|
+
redirects: T::Array[URI::Generic]).returns(Response)
|
|
186
|
+
end
|
|
187
|
+
def perform(uri, headers, sink, redirects)
|
|
188
|
+
response = T.let(nil, T.nilable(Response))
|
|
189
|
+
build_http(uri).start do |session|
|
|
190
|
+
session.request(Net::HTTP::Get.new(uri, headers)) do |raw|
|
|
191
|
+
response = receive(raw, uri, sink, redirects)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
T.must(response)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Only a successful body is streamed. An error page is small and the caller
|
|
198
|
+
# will want to read it, and writing one into the sink would hand the
|
|
199
|
+
# payload cache a 404 notice to checksum as though it were a list.
|
|
200
|
+
#
|
|
201
|
+
# A 304 is the one status with nothing to read either way: it is defined to
|
|
202
|
+
# carry no body, and handing back the empty string `read_body` produces
|
|
203
|
+
# would give conditional GET (#10) something a caller could try to parse.
|
|
204
|
+
sig do
|
|
205
|
+
params(raw: T.untyped, uri: URI::Generic, sink: T.untyped,
|
|
206
|
+
redirects: T::Array[URI::Generic]).returns(Response)
|
|
207
|
+
end
|
|
208
|
+
def receive(raw, uri, sink, redirects)
|
|
209
|
+
status = raw.code.to_i
|
|
210
|
+
body = T.let(nil, T.nilable(String))
|
|
211
|
+
if status == 304
|
|
212
|
+
nil
|
|
213
|
+
elsif sink && status.between?(200, 299)
|
|
214
|
+
rewind(sink)
|
|
215
|
+
raw.read_body { |chunk| sink.write(chunk) }
|
|
216
|
+
else
|
|
217
|
+
body = raw.read_body
|
|
218
|
+
end
|
|
219
|
+
Response.new(status: status, headers: raw.to_hash, uri: uri, body: body, redirects: redirects)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A read that dies mid-body has already written part of the file, so a
|
|
223
|
+
# retry has to start the sink over rather than append a second prefix to
|
|
224
|
+
# the first. An append-only sink cannot be reset; it is the caller's to
|
|
225
|
+
# handle, and #download's own path never produces one.
|
|
226
|
+
sig { params(sink: T.untyped).void }
|
|
227
|
+
def rewind(sink)
|
|
228
|
+
return unless sink.respond_to?(:truncate) && sink.respond_to?(:rewind)
|
|
229
|
+
|
|
230
|
+
sink.rewind
|
|
231
|
+
sink.truncate(0)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Exponential backoff, no jitter: this is one process fetching a handful of
|
|
235
|
+
# files on a schedule, not a fleet that needs de-synchronizing, and a
|
|
236
|
+
# deterministic delay is one less thing for a stuck sync to explain.
|
|
237
|
+
sig { params(uri: URI::Generic, block: T.proc.returns(Response)).returns(Response) }
|
|
238
|
+
def with_retries(uri, &block)
|
|
239
|
+
attempt = 0
|
|
240
|
+
loop do
|
|
241
|
+
attempt += 1
|
|
242
|
+
begin
|
|
243
|
+
response = block.call
|
|
244
|
+
return response unless retry_status?(response, attempt)
|
|
245
|
+
rescue *FATAL_ERRORS => e
|
|
246
|
+
raise ConnectionError.new(failure_message(uri, e, attempt), retryable: false)
|
|
247
|
+
rescue Timeout::Error => e
|
|
248
|
+
raise TimeoutError, failure_message(uri, e, attempt) unless attempt <= max_retries
|
|
249
|
+
rescue *TRANSIENT_ERRORS => e
|
|
250
|
+
raise ConnectionError, failure_message(uri, e, attempt) unless attempt <= max_retries
|
|
251
|
+
end
|
|
252
|
+
sleep(backoff_for(attempt))
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
sig { params(response: Response, attempt: Integer).returns(T::Boolean) }
|
|
257
|
+
def retry_status?(response, attempt)
|
|
258
|
+
response.server_error? && attempt <= max_retries
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
sig { params(attempt: Integer).returns(Numeric) }
|
|
262
|
+
def backoff_for(attempt)
|
|
263
|
+
retry_backoff * (2**(attempt - 1))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
sig { params(uri: URI::Generic).returns(Net::HTTP) }
|
|
267
|
+
def build_http(uri)
|
|
268
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
269
|
+
http.use_ssl = uri.scheme == "https"
|
|
270
|
+
http.open_timeout = open_timeout
|
|
271
|
+
http.read_timeout = read_timeout
|
|
272
|
+
http
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The configured agent unless the caller overrode it, compared case-
|
|
276
|
+
# insensitively because HTTP header names are. Validating the merged value
|
|
277
|
+
# rather than only the configured one is what makes the guarantee real: the
|
|
278
|
+
# request that goes out is the one checked, and it is checked here, before
|
|
279
|
+
# a socket is opened.
|
|
280
|
+
sig { params(extra: T.untyped).returns(T::Hash[String, String]) }
|
|
281
|
+
def headers!(extra)
|
|
282
|
+
headers = extra.to_h.to_h { |name, value| [name.to_s, value.to_s] }
|
|
283
|
+
key = headers.keys.find { |name| name.casecmp?("user-agent") }
|
|
284
|
+
agent = key ? headers[key] : user_agent
|
|
285
|
+
Configuration.user_agent!(agent)
|
|
286
|
+
headers.delete(key) if key
|
|
287
|
+
headers.merge("User-Agent" => agent)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
sig { params(url: T.untyped).returns(URI::Generic) }
|
|
291
|
+
def uri!(url)
|
|
292
|
+
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
293
|
+
raise InvalidArgument, "#{url.inspect} is not an http(s) URL" unless uri.is_a?(URI::HTTP) && uri.host
|
|
294
|
+
|
|
295
|
+
uri
|
|
296
|
+
rescue URI::InvalidURIError => e
|
|
297
|
+
raise InvalidArgument, "#{url.inspect} is not a URL: #{e.message}"
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# `Location` is allowed to be relative, and publishers use that, so it is
|
|
301
|
+
# resolved against the URL that produced it rather than parsed alone.
|
|
302
|
+
sig { params(from: URI::Generic, location: T.untyped, seen: T::Array[String]).returns(URI::Generic) }
|
|
303
|
+
def next_hop(from, location, seen)
|
|
304
|
+
target = URI.join(from, location)
|
|
305
|
+
raise InvalidRedirect, "#{from} redirected to #{location.inspect}" unless target.is_a?(URI::HTTP) && target.host
|
|
306
|
+
raise RedirectLoop, "#{from} redirected back to #{target}" if seen.include?(target.to_s)
|
|
307
|
+
|
|
308
|
+
seen << target.to_s
|
|
309
|
+
target
|
|
310
|
+
rescue URI::Error => e
|
|
311
|
+
raise InvalidRedirect, "#{from} redirected to an unusable location #{location.inspect}: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
sig { params(redirects: T::Array[URI::Generic]).returns(String) }
|
|
315
|
+
def chain_message(redirects)
|
|
316
|
+
"#{redirects.first} exceeded #{max_redirects} redirects: #{redirects.join(" -> ")}"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
sig { params(uri: URI::Generic, error: Exception, attempts: Integer).returns(String) }
|
|
320
|
+
def failure_message(uri, error, attempts)
|
|
321
|
+
"GET #{uri} failed after #{attempts} attempt#{"s" unless attempts == 1}: #{error.class}: #{error.message}"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/partial_date"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
# A document number a sanctions list published for an entity: passport,
|
|
10
|
+
# national ID, tax number, company registration. It is the highest-value
|
|
11
|
+
# signal the matcher has -- an exact passport match is near-decisive in the
|
|
12
|
+
# scorer (#32), where a name match never is.
|
|
13
|
+
#
|
|
14
|
+
# ActiveSanction::Identifier.new(
|
|
15
|
+
# kind: :passport,
|
|
16
|
+
# value: "AB-123 456",
|
|
17
|
+
# country: "Egypt",
|
|
18
|
+
# issued_on: PartialDate.parse("2004-06-01"),
|
|
19
|
+
# expires_on: PartialDate.parse("2009-05-31"),
|
|
20
|
+
# note: "expired"
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# `value` is the one required field: an identifier with no number is not an
|
|
24
|
+
# identifier. Everything else is optional, because OFAC's remarks often give
|
|
25
|
+
# just `Passport 123456 (Egypt)` and nothing more.
|
|
26
|
+
#
|
|
27
|
+
# The published string is kept verbatim -- it is what gets shown back to a
|
|
28
|
+
# user justifying a hit -- while comparison runs on #normalized_value, since
|
|
29
|
+
# two governments transcribing one passport rarely agree on its punctuation.
|
|
30
|
+
# Instances are frozen on construction and compare by value.
|
|
31
|
+
class Identifier
|
|
32
|
+
extend T::Sig
|
|
33
|
+
|
|
34
|
+
# The UN publishes TYPE_OF_DOCUMENT as free text ("Passport", "National
|
|
35
|
+
# Identification Number"), so adapters map onto these rather than passing a
|
|
36
|
+
# source's own vocabulary through. :other is a real answer, not a failure:
|
|
37
|
+
# a document we cannot classify still matches on its number.
|
|
38
|
+
KINDS = T.let(%i[passport national_id tax_id registration_number other].freeze, T::Array[Symbol])
|
|
39
|
+
|
|
40
|
+
# Canonical member order. Snapshot (#8) checksums the serialized form, so
|
|
41
|
+
# #to_h must lay its keys out the same way every time. `normalized_value`
|
|
42
|
+
# is derived rather than stored: a stored copy can disagree with the value
|
|
43
|
+
# it describes, and then two records that mean the same thing checksum
|
|
44
|
+
# apart.
|
|
45
|
+
#
|
|
46
|
+
# @api private
|
|
47
|
+
MEMBERS = T.let(%i[kind value country issued_on expires_on note].freeze, T::Array[Symbol])
|
|
48
|
+
|
|
49
|
+
# Everything a number is not: spaces, hyphens, slashes, dots. OFAC writes
|
|
50
|
+
# `AB-123 456` where the UN writes `AB123456`, and neither is more correct.
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
|
|
54
|
+
|
|
55
|
+
sig { returns(Symbol) }
|
|
56
|
+
attr_reader :kind
|
|
57
|
+
|
|
58
|
+
# The published string, verbatim: it is what gets shown back to whoever has
|
|
59
|
+
# to justify a hit.
|
|
60
|
+
sig { returns(String) }
|
|
61
|
+
attr_reader :value
|
|
62
|
+
|
|
63
|
+
sig { returns(T.nilable(String)) }
|
|
64
|
+
attr_reader :country
|
|
65
|
+
|
|
66
|
+
sig { returns(T.nilable(PartialDate)) }
|
|
67
|
+
attr_reader :issued_on
|
|
68
|
+
|
|
69
|
+
sig { returns(T.nilable(PartialDate)) }
|
|
70
|
+
attr_reader :expires_on
|
|
71
|
+
|
|
72
|
+
sig { returns(T.nilable(String)) }
|
|
73
|
+
attr_reader :note
|
|
74
|
+
|
|
75
|
+
# What comparison actually runs on -- see #== below.
|
|
76
|
+
sig { returns(String) }
|
|
77
|
+
attr_reader :normalized_value
|
|
78
|
+
|
|
79
|
+
# Rebuilds an identifier from #to_h output. Accepts string keys, and dates
|
|
80
|
+
# as the hashes JSON leaves behind, so a record survives a round-trip
|
|
81
|
+
# through storage (#24) without a separate coercion step.
|
|
82
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
83
|
+
def self.from_h(hash)
|
|
84
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
85
|
+
unknown = attributes.keys - MEMBERS
|
|
86
|
+
raise InvalidArgument, "unknown Identifier attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
87
|
+
|
|
88
|
+
# `new(**hash)` past a required keyword parameter is one of the few
|
|
89
|
+
# things Sorbet cannot check statically. #initialize validates what
|
|
90
|
+
# arrives, which is where a bad round-trip is caught.
|
|
91
|
+
T.unsafe(self).new(**attributes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `kind` defaults to :other because a number we cannot classify is still
|
|
95
|
+
# worth matching on -- OFAC remarks carry plenty of them.
|
|
96
|
+
#
|
|
97
|
+
# Untyped on purpose, and the same choice Entity makes: all six are the
|
|
98
|
+
# publisher's own text arriving as whatever the parser made of it. The two
|
|
99
|
+
# dates additionally accept a PartialDate, a #to_h hash or anything
|
|
100
|
+
# PartialDate.parse reads -- see #date_or_nil.
|
|
101
|
+
sig do
|
|
102
|
+
params(value: T.untyped, kind: T.untyped, country: T.untyped, issued_on: T.untyped,
|
|
103
|
+
expires_on: T.untyped, note: T.untyped).void
|
|
104
|
+
end
|
|
105
|
+
def initialize(value:, kind: :other, country: nil, issued_on: nil, expires_on: nil, note: nil)
|
|
106
|
+
@value = T.let(value!(value), String)
|
|
107
|
+
@normalized_value = T.let(-@value.downcase.gsub(INSIGNIFICANT, ""), String)
|
|
108
|
+
@kind = T.let(kind!(kind), Symbol)
|
|
109
|
+
@country = T.let(string_or_nil(country), T.nilable(String))
|
|
110
|
+
@issued_on = T.let(date_or_nil(:issued_on, issued_on), T.nilable(PartialDate))
|
|
111
|
+
@expires_on = T.let(date_or_nil(:expires_on, expires_on), T.nilable(PartialDate))
|
|
112
|
+
@note = T.let(string_or_nil(note), T.nilable(String))
|
|
113
|
+
freeze
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { returns(T::Boolean) }
|
|
117
|
+
def passport? = kind == :passport
|
|
118
|
+
|
|
119
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
120
|
+
def to_h
|
|
121
|
+
{
|
|
122
|
+
kind: kind,
|
|
123
|
+
value: value,
|
|
124
|
+
country: country,
|
|
125
|
+
issued_on: issued_on&.to_h,
|
|
126
|
+
expires_on: expires_on&.to_h,
|
|
127
|
+
note: note
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
sig { returns(String) }
|
|
132
|
+
def to_s = value
|
|
133
|
+
|
|
134
|
+
# Equality is the comparison the acceptance criteria asks for: `AB-123 456`
|
|
135
|
+
# and `ab123456` are one passport written down twice, and de-duplicating
|
|
136
|
+
# the same document across OFAC and the UN depends on saying so. Dates and
|
|
137
|
+
# notes stay out of the key -- publishers report them inconsistently, and
|
|
138
|
+
# letting them split one document into two records would defeat the dedup
|
|
139
|
+
# this exists for. Entity (#4) still compares its members through #to_h, so
|
|
140
|
+
# nothing here hides a differing published string from a record diff.
|
|
141
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
142
|
+
def ==(other)
|
|
143
|
+
return false unless other.instance_of?(self.class)
|
|
144
|
+
|
|
145
|
+
comparison_key == other.comparison_key
|
|
146
|
+
end
|
|
147
|
+
alias eql? ==
|
|
148
|
+
|
|
149
|
+
sig { returns(Integer) }
|
|
150
|
+
def hash
|
|
151
|
+
[self.class, comparison_key].hash
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sig { returns(String) }
|
|
155
|
+
def inspect
|
|
156
|
+
"#<#{self.class} #{kind.inspect} #{value.inspect}#{" country=#{country.inspect}" if country}>"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
protected
|
|
160
|
+
|
|
161
|
+
# Country is folded rather than dropped: two passports with the same number
|
|
162
|
+
# from different countries are different documents.
|
|
163
|
+
sig { returns([Symbol, String, T.nilable(String)]) }
|
|
164
|
+
def comparison_key
|
|
165
|
+
[kind, normalized_value, country&.downcase]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
sig { params(value: T.untyped).returns(String) }
|
|
171
|
+
def value!(value)
|
|
172
|
+
string = value.to_s.strip
|
|
173
|
+
raise InvalidArgument, "value is required" if string.empty?
|
|
174
|
+
raise InvalidArgument, "value has no alphanumerics: #{string.inspect}" if string.gsub(INSIGNIFICANT, "").empty?
|
|
175
|
+
|
|
176
|
+
-string
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Case is folded before the lookup: sources capitalize their document types
|
|
180
|
+
# however they like, and no adapter should have to remember which.
|
|
181
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
182
|
+
def kind!(value)
|
|
183
|
+
raise InvalidArgument, "kind is required" if value.nil? || value.to_s.empty?
|
|
184
|
+
|
|
185
|
+
symbol = value.to_s.downcase.to_sym
|
|
186
|
+
return symbol if KINDS.include?(symbol)
|
|
187
|
+
|
|
188
|
+
raise InvalidArgument, "unknown kind #{symbol.inspect}, expected one of #{KINDS.join(", ")}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
192
|
+
def string_or_nil(value)
|
|
193
|
+
return nil if value.nil?
|
|
194
|
+
|
|
195
|
+
string = value.to_s.strip
|
|
196
|
+
string.empty? ? nil : -string
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Issue and expiry dates arrive as imprecisely as any other list date -- the
|
|
200
|
+
# UN publishes year-only expiries -- so they are PartialDates. Hashes and
|
|
201
|
+
# strings are coerced, which is what makes a JSON round-trip land where it
|
|
202
|
+
# started.
|
|
203
|
+
sig { params(member: Symbol, value: T.untyped).returns(T.nilable(PartialDate)) }
|
|
204
|
+
def date_or_nil(member, value)
|
|
205
|
+
case value
|
|
206
|
+
when nil, PartialDate then value
|
|
207
|
+
when Hash then PartialDate.from_h(value)
|
|
208
|
+
else PartialDate.parse(value) || raise(InvalidArgument, "#{member} is not a date: #{value.inspect}")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
class Index
|
|
8
|
+
# The mutable half of an immutable index.
|
|
9
|
+
#
|
|
10
|
+
# Index.build is the entry point; this exists so that the thing a web
|
|
11
|
+
# process shares between threads has no `add` on it at all. An index that
|
|
12
|
+
# could be appended to would be an index that has to be locked, and the
|
|
13
|
+
# whole design is that it is not -- see Index on the swap.
|
|
14
|
+
#
|
|
15
|
+
# builder = ActiveSanction::Index::Builder.new
|
|
16
|
+
# store.each_entity { |entity| builder.add(entity) }
|
|
17
|
+
# index = builder.build
|
|
18
|
+
#
|
|
19
|
+
# A builder is single-threaded and single-use. Calling `build` twice
|
|
20
|
+
# returns two indexes over the same entries, which is harmless and not
|
|
21
|
+
# something anything needs.
|
|
22
|
+
class Builder
|
|
23
|
+
extend T::Sig
|
|
24
|
+
|
|
25
|
+
sig { void.checked(:tests) }
|
|
26
|
+
def initialize
|
|
27
|
+
@entries = T.let([], T::Array[Entry])
|
|
28
|
+
@tokens = T.let({}, T::Hash[String, T::Array[Integer]])
|
|
29
|
+
@trigrams = T.let({}, T::Hash[String, T::Array[Integer]])
|
|
30
|
+
@phonetics = T.let({}, T::Hash[String, T::Array[Integer]])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Every name the entity carries, folded under its type and posted to the
|
|
34
|
+
# three feature spaces.
|
|
35
|
+
#
|
|
36
|
+
# Two names are skipped, and both are skips rather than errors because a
|
|
37
|
+
# government file is not something a build gets to reject:
|
|
38
|
+
#
|
|
39
|
+
# **A name that folds to nothing.** Punctuation, an emoji, a row of
|
|
40
|
+
# dashes -- Form#empty? exists for these. Such a name cannot be scored,
|
|
41
|
+
# so indexing it would only produce candidates no comparison can rank.
|
|
42
|
+
#
|
|
43
|
+
# **A name that folds onto one this entity already has.** OFAC publishes
|
|
44
|
+
# `MUÑOZ HERMANOS S.A.` and `MUNOZ HERMANOS` on one record, and after
|
|
45
|
+
# the fold they are the same string. Keeping both would post the same
|
|
46
|
+
# entity twice under every one of its features, which costs memory on
|
|
47
|
+
# the way in and hands the scorer the same comparison twice on the way
|
|
48
|
+
# out. The publisher's own spelling is not lost: the first one to arrive
|
|
49
|
+
# keeps its Name, and that is what a hit is reported in.
|
|
50
|
+
sig { params(entity: Entity).returns(T.self_type).checked(:tests) }
|
|
51
|
+
def add(entity)
|
|
52
|
+
folded = T.let({}, T::Hash[String, TrueClass])
|
|
53
|
+
entity.names.each do |name|
|
|
54
|
+
form = Normalizer.call(name.value, type: entity.type)
|
|
55
|
+
next if form.empty? || folded.key?(form.value)
|
|
56
|
+
|
|
57
|
+
folded[form.value] = true
|
|
58
|
+
index(Entry.new(id: @entries.size, entity: entity, name: name, form: form))
|
|
59
|
+
end
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The finished index. Everything it holds is frozen on the way in.
|
|
64
|
+
sig { returns(Index).checked(:tests) }
|
|
65
|
+
def build
|
|
66
|
+
Index.new(entries: @entries, tokens: @tokens, trigrams: @trigrams, phonetics: @phonetics)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
sig { params(entry: Entry).void }
|
|
72
|
+
def index(entry)
|
|
73
|
+
@entries << entry
|
|
74
|
+
post(@tokens, Features.tokens(entry.form), entry.id)
|
|
75
|
+
post(@trigrams, Features.trigrams(entry.form), entry.id)
|
|
76
|
+
post(@phonetics, Features.phonetics(entry.form), entry.id)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Ids arrive in ascending order because they are assigned in the order
|
|
80
|
+
# entries are made, so a posting list is sorted without ever being
|
|
81
|
+
# sorted. Query relies on that only for determinism; nothing binary
|
|
82
|
+
# searches these.
|
|
83
|
+
sig { params(postings: T::Hash[String, T::Array[Integer]], features: T::Array[String], id: Integer).void }
|
|
84
|
+
def post(postings, features, id)
|
|
85
|
+
features.each { |feature| (postings[feature] ||= []) << id }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
class Index
|
|
8
|
+
# A name the index thinks is worth comparing, and how strongly it thought
|
|
9
|
+
# so.
|
|
10
|
+
#
|
|
11
|
+
# candidate.name.value # => "ABBAS, Abu"
|
|
12
|
+
# candidate.form.value # => "abbas abu"
|
|
13
|
+
# candidate.weight # => 14.82
|
|
14
|
+
#
|
|
15
|
+
# ### `weight` is not a score
|
|
16
|
+
#
|
|
17
|
+
# It is the sum of what the query and this name have in common, each
|
|
18
|
+
# shared feature counted by how rare it is -- see Index for the arithmetic.
|
|
19
|
+
# It says how confident the retrieval was, in a unit with no upper bound
|
|
20
|
+
# and no meaning outside the corpus it was computed against: a long name
|
|
21
|
+
# with rare tokens outranks a short one with common tokens before either
|
|
22
|
+
# has been compared to anything.
|
|
23
|
+
#
|
|
24
|
+
# So it is not comparable with the 0..100 a MatchResult (#33) carries, it
|
|
25
|
+
# is not a threshold anybody should set, and it must never reach a
|
|
26
|
+
# compliance user. Its one job is ordering the cap -- when more names
|
|
27
|
+
# match than the caller asked for, this decides which are dropped -- and
|
|
28
|
+
# it is exposed rather than hidden because that decision is the one thing
|
|
29
|
+
# about this stage that can silently cost a true match, and a caller
|
|
30
|
+
# investigating why a name was missed needs to see where it fell.
|
|
31
|
+
class Candidate
|
|
32
|
+
extend T::Sig
|
|
33
|
+
|
|
34
|
+
sig { returns(Entry).checked(:tests) }
|
|
35
|
+
attr_reader :entry
|
|
36
|
+
|
|
37
|
+
sig { returns(Float).checked(:tests) }
|
|
38
|
+
attr_reader :weight
|
|
39
|
+
|
|
40
|
+
sig { params(entry: Entry, weight: Float).void.checked(:tests) }
|
|
41
|
+
def initialize(entry:, weight:)
|
|
42
|
+
@entry = entry
|
|
43
|
+
@weight = weight
|
|
44
|
+
freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { returns(Entity).checked(:tests) }
|
|
48
|
+
def entity = entry.entity
|
|
49
|
+
|
|
50
|
+
sig { returns(Name).checked(:tests) }
|
|
51
|
+
def name = entry.name
|
|
52
|
+
|
|
53
|
+
sig { returns(Normalizer::Form).checked(:tests) }
|
|
54
|
+
def form = entry.form
|
|
55
|
+
|
|
56
|
+
sig { returns(Symbol).checked(:tests) }
|
|
57
|
+
def source = entry.source
|
|
58
|
+
|
|
59
|
+
sig { returns(String) }
|
|
60
|
+
def inspect = "#<#{self.class} #{name.value.inspect} weight=#{weight.round(2)}>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|