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.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,341 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/configuration"
7
+ require "active_sanction/error"
8
+ require "active_sanction/match_result"
9
+
10
+ module ActiveSanction
11
+ # One configured installation of this library, as an object a server can
12
+ # hold.
13
+ #
14
+ # client = ActiveSanction::Client.new(
15
+ # storage: ActiveSanction::Storage::FileSystem.new(root: "/srv/sanctions"),
16
+ # sources: %i[ofac_sdn un_consolidated canada_sema],
17
+ # user_agent: "acme-bank/1.0 (compliance@acme.example)"
18
+ # )
19
+ #
20
+ # client.sync!
21
+ # client.screen(name: "Bosco Ntaganda", threshold: 75)
22
+ #
23
+ # `ActiveSanction.screen` and everything beside it are sugar over a default
24
+ # client this module builds on first use, so a script and the README
25
+ # quickstart never have to know this class exists. What it exists for is the
26
+ # process that needs more than one configuration alive at once, which a
27
+ # process-global cannot express at all:
28
+ #
29
+ # - **A pinned list version beside the current one.** An audit re-run has to
30
+ # screen against the snapshot a decision was actually made under while
31
+ # live traffic screens against today's. Two clients over two stores, both
32
+ # warm, neither reaching for the other's.
33
+ # - **Different lists per tenant.** One customer's obligations are OFAC and
34
+ # the UN; another's add the EU and the UK. A client per source set, held
35
+ # for the life of the process.
36
+ # - **A different publisher identity per caller**, which is the fetch half of
37
+ # the same thing.
38
+ #
39
+ # ### It is a value object with one memo
40
+ #
41
+ # Everything a client is asked is answered from a frozen Configuration --
42
+ # the store, the source list, the weights, the candidate cap, the thresholds
43
+ # a query defaults to, the User-Agent every request carries. A client's
44
+ # settings cannot be edited after it is built. Deriving a neighbour from one
45
+ # is what `#with` is for:
46
+ #
47
+ # audit = client.with(storage: pinned_store)
48
+ #
49
+ # The one thing a client holds that is not settled at construction is the
50
+ # matcher, because building one reads every stored list and indexes it --
51
+ # seconds and tens of megabytes on a full corpus. It is built on first use
52
+ # under a lock and then only read. See #matcher.
53
+ #
54
+ # ### The thread-safety contract
55
+ #
56
+ # **A built client and the index it has loaded are safe to screen from
57
+ # concurrently.** `#screen`, `#screen_all`, `#matcher` and every reader here
58
+ # may be called from as many threads as a host has; the matcher is frozen at
59
+ # build and nothing on the query path writes to anything shared. This is the
60
+ # guarantee a web process needs, and it is the reason the matcher is
61
+ # replaced rather than updated.
62
+ #
63
+ # **`#sync!` is not concurrent-safe against readers of the same storage.**
64
+ # It is safe to run alongside screening -- a store publishes a list whole,
65
+ # so a thread mid-screen finishes against the version it started with, and
66
+ # `#reload!` is what moves the next screening call onto the new one. What is
67
+ # not supported is two syncs writing the same store at the same time, from
68
+ # this process or another: the last writer wins per source and the losers'
69
+ # downloads are discarded. A host that syncs from more than one process
70
+ # needs a lock of its own around the run, not a bigger `sync_concurrency:`
71
+ # -- that number is how many *publishers* one run fetches from at once, and
72
+ # is safe.
73
+ #
74
+ # **Nothing here makes a store thread-safe that is not.** The two shipped
75
+ # adapters are: Memory guards its hash, and FileSystem publishes a list by
76
+ # renaming one file over another. An adapter a host wrote is held to the
77
+ # same rule by the shared conformance group.
78
+ class Client
79
+ extend T::Sig
80
+
81
+ # The settings this client answers from, frozen. Reading one is how a host
82
+ # asks what a client is: `client.configuration.user_agent`.
83
+ sig { returns(Configuration) }
84
+ attr_reader :configuration
85
+
86
+ # Where screening happened, stamped onto every result. `:local` is this
87
+ # gem doing the work against a list on this machine; #56 introduces the
88
+ # seam and the names of the others.
89
+ sig { returns(Symbol) }
90
+ attr_reader :backend
91
+
92
+ # Takes a Configuration, or the settings to build one from, or both -- in
93
+ # which case the settings are applied on top of a copy and the
94
+ # configuration handed in is left alone:
95
+ #
96
+ # ActiveSanction::Client.new(user_agent: "acme-bank/1.0 (compliance@acme.example)")
97
+ # ActiveSanction::Client.new(configuration: ActiveSanction.config)
98
+ # ActiveSanction::Client.new(configuration: base, sources: %i[ofac_sdn])
99
+ #
100
+ # Every setting `ActiveSanction.configure` takes is a keyword argument
101
+ # here, held to exactly the same rule and failing with the same message --
102
+ # they are the same writers. An unknown one raises ConfigurationError
103
+ # rather than being ignored, because a misspelled setting that is silently
104
+ # dropped is a client running on a default somebody thinks they changed.
105
+ #
106
+ # A configuration passed in with no overrides is frozen in place rather
107
+ # than copied, so `ActiveSanction.config` is the object the `configure`
108
+ # block just wrote to. That is the one visible side effect of building a
109
+ # client, and it is the point: a settings object that can still move is
110
+ # the thing this class exists to remove.
111
+ sig { params(configuration: T.nilable(Configuration), backend: T.untyped, settings: T.untyped).void }
112
+ def initialize(configuration: nil, backend: MatchResult::DEFAULT_BACKEND, **settings)
113
+ base = configuration || Configuration.new
114
+ @configuration = T.let((settings.empty? ? base : base.with(**settings)).freeze, Configuration)
115
+ @backend = T.let(backend.to_s.to_sym, Symbol)
116
+ @lock = T.let(Mutex.new, Mutex)
117
+ @matcher = T.let(nil, T.nilable(Matcher))
118
+ end
119
+
120
+ # Another client with some settings changed, sharing nothing with this one
121
+ # -- not the configuration, and not the matcher:
122
+ #
123
+ # audit = client.with(storage: januarys_snapshots)
124
+ #
125
+ # The second client builds its own index over its own store, which is the
126
+ # cost of the isolation and the reason this is an explicit call rather
127
+ # than a per-query option.
128
+ sig { params(overrides: T.untyped).returns(Client) }
129
+ def with(**overrides)
130
+ self.class.new(configuration: configuration.with(**overrides), backend: backend)
131
+ end
132
+
133
+ # Where this client's synced lists are read from and written to.
134
+ sig { returns(Storage::Base) }
135
+ def storage = configuration.storage
136
+
137
+ # The lists this client syncs and screens against, or nil for every
138
+ # registered source -- which is what an installation that has not named
139
+ # any should get, so that a gem adding a jurisdiction takes effect without
140
+ # an edit to the host's initializer.
141
+ sig { returns(T.nilable(T::Array[Symbol])) }
142
+ def sources = configuration.sources
143
+
144
+ # This client's matcher, built from its store on first use and then held.
145
+ #
146
+ # Building it reads every list this client's configuration names, indexes
147
+ # them, and takes each list's checksum from the very snapshot it indexed.
148
+ # That costs seconds and tens of megabytes on a full corpus, so it happens
149
+ # once, under a lock -- two threads racing to do it at boot would build
150
+ # two indexes and throw one away.
151
+ #
152
+ # What comes back is immutable and safe to screen from concurrently. It is
153
+ # never updated: a sync builds a new one, which is what `#reload!` makes
154
+ # the next screening call do.
155
+ sig { returns(Matcher) }
156
+ def matcher
157
+ @lock.synchronize do
158
+ @matcher ||= with_configuration do
159
+ Matcher.build(storage, sources: configuration.sources, weights: configuration.scorer_weights,
160
+ candidate_limit: configuration.candidate_limit, backend: backend)
161
+ end
162
+ end
163
+ end
164
+
165
+ # Screens one name against this client's lists:
166
+ #
167
+ # client.screen(name: "Bosco Ntaganda", type: :individual, threshold: 75)
168
+ #
169
+ # Sugar over #matcher, which is where everything this does is documented.
170
+ # A query that names no threshold or limit takes this client's, not the
171
+ # default client's.
172
+ sig { params(query: T.untyped, overrides: T.untyped).returns(T::Array[MatchResult]) }
173
+ def screen(query = nil, **overrides)
174
+ built = matcher
175
+ with_configuration { built.screen(query, **overrides) }
176
+ end
177
+
178
+ # A book of names against one list version, one array of results per
179
+ # query, in the order they were given. See Matcher#screen_all.
180
+ sig { params(queries: T.untyped, overrides: T.untyped).returns(T::Array[T::Array[MatchResult]]) }
181
+ def screen_all(queries, **overrides)
182
+ built = matcher
183
+ with_configuration { built.screen_all(queries, **overrides) }
184
+ end
185
+
186
+ # Fetches, parses and stores this client's lists, and returns what each
187
+ # one did:
188
+ #
189
+ # report = client.sync! # every source this client names
190
+ # report = client.sync!(:ofac_sdn) # one
191
+ # report = client.sync!(force: true) # bypass conditional GET
192
+ #
193
+ # A failing source does not raise and does not stop the others: it is
194
+ # captured into the report and **its previous snapshot is kept**. See
195
+ # Sync, which is where all of that is documented, and Sync::Report.
196
+ #
197
+ # Drops this client's matcher when any list changed, so the next screening
198
+ # call is answered by what was just synced. Not concurrent-safe against
199
+ # another sync of the same storage -- see the class comment.
200
+ sig do
201
+ params(sources: T.untyped, options: T.untyped,
202
+ block: T.nilable(T.proc.params(result: Sync::Result).void)).returns(Sync::Report)
203
+ end
204
+ def sync!(*sources, **options, &block)
205
+ report = with_configuration do
206
+ T.unsafe(Sync).new(sources: sources, store: storage, **options).call(&block)
207
+ end
208
+ reload! if report.updated.any?
209
+ report
210
+ end
211
+
212
+ # What changed between two snapshots of one of this client's lists, read
213
+ # from this client's store. See Diff.
214
+ sig { params(source: T.untyped, options: T.untyped).returns(Diff) }
215
+ def diff(source = nil, **options)
216
+ with_configuration { T.unsafe(Diff).call(source, store: storage, **options) }
217
+ end
218
+
219
+ # Applies a snapshot diff to a book of subjects, and returns the alerts:
220
+ #
221
+ # diff = client.diff(:ofac_sdn, from: yesterdays_snapshot)
222
+ # alerts = client.rescreen(book, diff: diff, threshold: 75)
223
+ #
224
+ # alerts.first.subject_id # => "cust_1"
225
+ # alerts.first.change # => :newly_listed
226
+ #
227
+ # Who a list change affects, which is the step that turns a diff into an
228
+ # alert. Costs the book times the handful of records that moved rather
229
+ # than the book times the whole corpus, and does not touch this client's
230
+ # matcher -- a rescreen indexes the diff and nothing else. A host
231
+ # streaming a large book builds one Rescreen and calls it per batch, so
232
+ # that index is built once; see Rescreen, which is where all of it is
233
+ # documented.
234
+ sig do
235
+ params(subjects: T.untyped, diff: T.untyped, options: T.untyped,
236
+ block: T.nilable(T.proc.params(alert: Rescreen::Alert).void)).returns(T::Array[Rescreen::Alert])
237
+ end
238
+ def rescreen(subjects, diff:, **options, &block)
239
+ with_configuration do
240
+ T.unsafe(Rescreen).call(subjects, diff: diff, backend: backend, **options, &block)
241
+ end
242
+ end
243
+
244
+ # Writes one of this client's lists to a portable bundle file, and returns
245
+ # the Bundle::Header it wrote:
246
+ #
247
+ # client.export(:ofac_sdn, to: "ofac_sdn.asb")
248
+ # client.export(:ofac_sdn, to: "ofac_sdn.asb", sign_with: private_key)
249
+ # client.export(snapshot, to: "ofac_sdn.asb") # one just synced, unstored
250
+ #
251
+ # The file that comes out is the unit of distribution: another machine loads
252
+ # it with #import and screens against it without ever reaching the
253
+ # publisher. See Snapshot::Bundle, and docs/bundle_format.md.
254
+ sig do
255
+ params(source: T.untyped, to: T.untyped, sign_with: T.untyped,
256
+ generator: T.untyped).returns(Snapshot::Bundle::Header)
257
+ end
258
+ def export(source, to:, sign_with: nil, generator: nil)
259
+ snapshot = source.is_a?(Snapshot) ? source : with_configuration { storage.fetch_snapshot(source) }
260
+ ::File.open(to.to_s, "wb") do |io|
261
+ Snapshot::Bundle.write(snapshot, io: io, sign_with: sign_with, generator: generator)
262
+ end
263
+ end
264
+
265
+ # Loads a bundle into this client's store and returns the Snapshot it held:
266
+ #
267
+ # client.import("ofac_sdn.asb") # unverified, and usable
268
+ # client.import("ofac_sdn.asb", verify_with: public_key)
269
+ #
270
+ # With a key, a bundle that was not signed by its holder raises rather than
271
+ # being stored -- an unverified list is a fine thing to screen against, and
272
+ # a list that failed a verification somebody asked for is not.
273
+ #
274
+ # Drops this client's matcher, so the next screening call is answered by
275
+ # what was just imported.
276
+ #
277
+ # ### What comes back is trusted; what is stored is not
278
+ #
279
+ # The snapshot returned reports `trusted?` when it verified. Reading the
280
+ # same list back out of a FileSystem or ActiveRecord store afterwards does
281
+ # not: a signature attests to the bundle's bytes, not to the copy this gem
282
+ # rewrote into its own layout. An installation that wants
283
+ # `MatchResult#verified?` on its results holds the imported snapshot in
284
+ # memory -- `client.with(storage: ActiveSanction::Storage::Memory.new)` --
285
+ # rather than round-tripping it through a directory. See Snapshot#trusted?.
286
+ sig { params(path: T.untyped, verify_with: T.untyped).returns(Snapshot) }
287
+ def import(path, verify_with: nil)
288
+ snapshot = ::File.open(path.to_s, "rb") { |io| Snapshot::Bundle.read(io, verify_with: verify_with) }
289
+ with_configuration { storage.write_snapshot(snapshot) }
290
+ reload!
291
+ snapshot
292
+ end
293
+
294
+ # Diagnoses whether one of this client's sources has changed format,
295
+ # against the version its store holds. Writes nothing. See Doctor.
296
+ sig do
297
+ params(sources: T.untyped, options: T.untyped,
298
+ block: T.nilable(T.proc.params(diagnosis: Doctor::Diagnosis).void)).returns(Doctor::Report)
299
+ end
300
+ def doctor(*sources, **options, &block)
301
+ with_configuration do
302
+ T.unsafe(Doctor).new(sources: sources, store: storage, **options).call(&block)
303
+ end
304
+ end
305
+
306
+ # Drops this client's matcher so the next screening call builds one over
307
+ # what its store holds now. What a host calls after syncing from another
308
+ # process -- `#sync!` calls it itself when a list changed.
309
+ #
310
+ # A screening call already in flight keeps the matcher it started with and
311
+ # finishes against one consistent list version, which is what makes its
312
+ # results re-derivable.
313
+ sig { returns(T.self_type) }
314
+ def reload!
315
+ @lock.synchronize { @matcher = nil }
316
+ self
317
+ end
318
+
319
+ # Whether a matcher has been built and is being held. What a host checks
320
+ # to decide whether a screening call is about to cost an index build.
321
+ sig { returns(T::Boolean) }
322
+ def loaded? = !@lock.synchronize { @matcher }.nil?
323
+
324
+ sig { returns(String) }
325
+ def inspect
326
+ named = sources ? T.must(sources).join(", ") : "every registered source"
327
+ "#<#{self.class} #{named} in #{storage.class}#{" loaded" if loaded?}>"
328
+ end
329
+
330
+ private
331
+
332
+ # Runs a block with this client's settings in force, so that everything
333
+ # reached from inside it -- the fetch layer's User-Agent and timeouts, the
334
+ # normalizer's dictionary, the XML backend, the threshold and limit a
335
+ # query defaults to -- reads this client's numbers rather than the default
336
+ # client's. See ActiveSanction.with_configuration, which is where the
337
+ # mechanism and its one limit are documented.
338
+ sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
339
+ def with_configuration(&block) = ActiveSanction.with_configuration(configuration, &block)
340
+ end
341
+ end