active_sanction 1.0.1 → 1.1.1
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/CHANGELOG.md +110 -1
- data/CONTRIBUTING.md +21 -4
- data/README.md +1 -1
- data/docs/adding_a_source.md +1 -1
- data/docs/api_stability.md +79 -7
- data/lib/active_sanction/client.rb +48 -2
- data/lib/active_sanction/configuration.rb +39 -0
- data/lib/active_sanction/fetcher.rb +38 -12
- data/lib/active_sanction/index.rb +61 -0
- data/lib/active_sanction/instrumentation/event.rb +122 -0
- data/lib/active_sanction/instrumentation/notifications.rb +122 -0
- data/lib/active_sanction/instrumentation.rb +245 -0
- data/lib/active_sanction/matcher.rb +82 -17
- data/lib/active_sanction/sources/base.rb +48 -6
- data/lib/active_sanction/sync.rb +54 -6
- data/lib/active_sanction/testing/sanction_source.rb +234 -0
- data/lib/active_sanction/testing/storage_adapter.rb +319 -0
- data/lib/active_sanction/testing/storage_adapter_defaults.rb +25 -0
- data/lib/active_sanction/testing.rb +119 -0
- data/lib/active_sanction/version.rb +1 -1
- data/lib/active_sanction.rb +1 -0
- metadata +9 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The contract every storage adapter must satisfy, written once.
|
|
5
|
+
#
|
|
6
|
+
# Loaded by `require "active_sanction/testing"` -- see ActiveSanction::Testing,
|
|
7
|
+
# which is where the entry point and the fixture root are documented.
|
|
8
|
+
#
|
|
9
|
+
# RSpec.describe ActiveSanction::Storage::Memory do
|
|
10
|
+
# it_behaves_like "a storage adapter"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# An adapter that needs something to be constructed -- a root directory, a
|
|
14
|
+
# connection -- says how in the customization block, which is evaluated after
|
|
15
|
+
# this group and so wins. It is a method rather than a `let` because a couple
|
|
16
|
+
# of the examples need a second, independent store:
|
|
17
|
+
#
|
|
18
|
+
# RSpec.describe ActiveSanction::Storage::FileSystem do
|
|
19
|
+
# it_behaves_like "a storage adapter" do
|
|
20
|
+
# def build_store = described_class.new(root: Dir.mktmpdir)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# ### What this is for
|
|
25
|
+
#
|
|
26
|
+
# The matcher (#33) is written against Storage::Base and never against a
|
|
27
|
+
# concrete store, which is the whole point of the interface: an installation
|
|
28
|
+
# swaps gzipped JSON for Postgres without touching the code that decides
|
|
29
|
+
# whether two names are the same person. That only holds if every adapter
|
|
30
|
+
# really does mean the same thing by these five methods, and "the same thing"
|
|
31
|
+
# is otherwise a paragraph in a design document that each new adapter
|
|
32
|
+
# re-interprets. Here it is executable.
|
|
33
|
+
#
|
|
34
|
+
# Most of what it checks is a way of losing records quietly. A store that
|
|
35
|
+
# returns an empty snapshot for a source nobody ever synced, one that drops the
|
|
36
|
+
# third of four entities on the way back, one that reorders them, one that
|
|
37
|
+
# accumulates two writes of a list instead of replacing it -- none of those
|
|
38
|
+
# raise, all of them return something that looks like a sanctions list, and the
|
|
39
|
+
# report they produce says the name you screened is clear.
|
|
40
|
+
#
|
|
41
|
+
# ### What it does not do
|
|
42
|
+
#
|
|
43
|
+
# It says nothing about durability, concurrency or performance, which are the
|
|
44
|
+
# things the adapters genuinely differ on: that a FileSystem write is atomic
|
|
45
|
+
# (#24), that an ActiveRecord write is one transaction (#25), that a Memory
|
|
46
|
+
# store is safe to screen from on many threads. Those are properties of one
|
|
47
|
+
# implementation and each adapter's own spec has to make them.
|
|
48
|
+
RSpec.shared_examples "a storage adapter" do
|
|
49
|
+
include ActiveSanction::Testing::StorageAdapterDefaults
|
|
50
|
+
|
|
51
|
+
let(:store) { build_store }
|
|
52
|
+
let(:ofac) { snapshot(:ofac_sdn, %w[2674 1234]) }
|
|
53
|
+
let(:un) { snapshot(:un_consolidated, %w[QDi.001]) }
|
|
54
|
+
|
|
55
|
+
# A record carrying every kind of member the canonical model has, because
|
|
56
|
+
# what a store loses is usually nested: a store that persists names and drops
|
|
57
|
+
# identifiers passes a contract written against bare strings.
|
|
58
|
+
def entity(source, ref)
|
|
59
|
+
ActiveSanction::Entity.new(
|
|
60
|
+
source: source, source_ref: ref, type: :individual,
|
|
61
|
+
names: [ActiveSanction::Name.new(value: "AL ZAWAHIRI #{ref}, Aiman", kind: :primary)],
|
|
62
|
+
addresses: [ActiveSanction::Address.new(country: "EG", city: "Cairo")],
|
|
63
|
+
identifiers: [ActiveSanction::Identifier.new(kind: :passport, value: ref, country: "EG")],
|
|
64
|
+
dates_of_birth: [ActiveSanction::PartialDate.parse("1951-06-19")],
|
|
65
|
+
nationalities: ["EG"], programs: ["SDGT"],
|
|
66
|
+
listed_on: ActiveSanction::PartialDate.parse("2001-09-23"),
|
|
67
|
+
remarks: "DOB 19 Jun 1951; POB Egypt"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def snapshot(source, refs)
|
|
72
|
+
ActiveSanction::Snapshot.new(
|
|
73
|
+
source: source, entities: refs.map { |ref| entity(source, ref) },
|
|
74
|
+
fetched_at: Time.utc(2026, 8, 28, 9, 30, 0), source_version: "Thu, 28 Aug 2026 09:00:00 GMT"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def entity_refs(entities) = entities.map(&:source_ref)
|
|
79
|
+
|
|
80
|
+
describe "a source that has never been synced" do
|
|
81
|
+
# Nil says "we have never fetched this". An empty snapshot says "this list
|
|
82
|
+
# has nobody on it", which no sanctions list has ever said, and a caller
|
|
83
|
+
# that cannot tell them apart screens against nothing and reports clear.
|
|
84
|
+
it "reads back as nil rather than as a list with nobody on it" do
|
|
85
|
+
expect(store.read_snapshot(:ofac_sdn)).to be_nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "has no metadata to report" do
|
|
89
|
+
expect(store.snapshot_meta(:ofac_sdn)).to be_nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "is not among the stored sources" do
|
|
93
|
+
expect(store.sources).not_to include(:ofac_sdn)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "is not reported as stored" do
|
|
97
|
+
expect(store).not_to be_stored(:ofac_sdn)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The other half of returning nil: a caller that named the list itself gets
|
|
101
|
+
# an exception rather than an empty result it has to remember to check.
|
|
102
|
+
it "raises when a caller asks for it by name" do
|
|
103
|
+
expect { store.fetch_snapshot(:ofac_sdn) }.to raise_error(ActiveSanction::Storage::MissingSnapshot)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "deletes without complaint, and says there was nothing to delete" do
|
|
107
|
+
expect(store.delete_snapshot(:ofac_sdn)).to be_falsey
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe "what comes back out" do
|
|
112
|
+
before { store.write_snapshot(ofac) }
|
|
113
|
+
|
|
114
|
+
it "returns the snapshot that was written" do
|
|
115
|
+
expect(store.read_snapshot(:ofac_sdn)).to eq(ofac)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Snapshot#== compares checksums, so the example above passes for a store
|
|
119
|
+
# that kept the checksum and lost the list. This is the one that does not.
|
|
120
|
+
it "returns every record, field for field" do
|
|
121
|
+
expect(store.read_snapshot(:ofac_sdn).entities.map(&:to_h)).to eq(ofac.entities.map(&:to_h))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A store that hands back half-deserialized records makes every caller
|
|
125
|
+
# downstream coerce before it can read a name.
|
|
126
|
+
it "returns Entities, not the hashes they serialize to" do
|
|
127
|
+
expect(store.read_snapshot(:ofac_sdn).entities).to all(be_an(ActiveSanction::Entity))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Order is not in the checksum -- Snapshot sorts fingerprints before
|
|
131
|
+
# folding them -- so nothing else catches a store that returns its rows in
|
|
132
|
+
# whatever order the database felt like. A report whose rows move between
|
|
133
|
+
# two runs against an unchanged list is a report an examiner cannot cite.
|
|
134
|
+
it "returns the records in the order they were written" do
|
|
135
|
+
expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(entity_refs(ofac.entities))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "returns the checksum the snapshot was written with" do
|
|
139
|
+
expect(store.read_snapshot(:ofac_sdn).checksum).to eq(ofac.checksum)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# To the second, which is what Snapshot#to_h serializes. An adapter that
|
|
143
|
+
# loses it cannot answer how old the list it is screening against is (#34).
|
|
144
|
+
it "returns the time the list was fetched" do
|
|
145
|
+
expect(store.read_snapshot(:ofac_sdn).fetched_at).to eq(ofac.fetched_at)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "returns the publisher's own version marker" do
|
|
149
|
+
expect(store.read_snapshot(:ofac_sdn).source_version).to eq(ofac.source_version)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Keys arrive as symbols from an adapter and as strings from anything that
|
|
153
|
+
# has been through JSON or a command line.
|
|
154
|
+
it "answers to the source name spelled as a string" do
|
|
155
|
+
expect(store.read_snapshot("ofac_sdn")).to eq(ofac)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "lists the source it stored" do
|
|
159
|
+
expect(store.sources).to eq([:ofac_sdn])
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe "what it refuses" do
|
|
164
|
+
# A Hash of the right shape carries a checksum somebody typed; only a
|
|
165
|
+
# Snapshot carries one computed over its own content, and that is the whole
|
|
166
|
+
# basis on which a stored list can be cited months later.
|
|
167
|
+
it "refuses to store something that is not a Snapshot" do
|
|
168
|
+
expect { store.write_snapshot(ofac.to_h) }.to raise_error(ArgumentError)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Source keys are typed by humans and are directory names to any adapter
|
|
172
|
+
# that writes files, so they are held to one rule everywhere.
|
|
173
|
+
it "refuses a name that is not a usable source key" do
|
|
174
|
+
expect { store.read_snapshot("") }.to raise_error(ActiveSanction::Error)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
describe "writing" do
|
|
179
|
+
it "returns the snapshot it stored" do
|
|
180
|
+
expect(store.write_snapshot(ofac)).to eq(ofac)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# A sync replaces a list; it does not add to one. A store that accumulates
|
|
184
|
+
# holds a delisted person forever, which is a false hit on every screening
|
|
185
|
+
# run from then on.
|
|
186
|
+
it "replaces a list rather than accumulating two of them" do
|
|
187
|
+
store.write_snapshot(ofac)
|
|
188
|
+
store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
|
|
189
|
+
|
|
190
|
+
expect(entity_refs(store.read_snapshot(:ofac_sdn).entities)).to eq(%w[9999])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "files a rewritten source once" do
|
|
194
|
+
store.write_snapshot(ofac)
|
|
195
|
+
store.write_snapshot(snapshot(:ofac_sdn, %w[9999]))
|
|
196
|
+
|
|
197
|
+
expect(store.sources).to eq([:ofac_sdn])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Per-source isolation is the rule sync orchestration (#34) is built on: one
|
|
201
|
+
# list failing must leave the others exactly as they were.
|
|
202
|
+
it "leaves the other lists alone" do
|
|
203
|
+
store.write_snapshot(un)
|
|
204
|
+
store.write_snapshot(ofac)
|
|
205
|
+
|
|
206
|
+
expect(store.read_snapshot(:un_consolidated)).to eq(un)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it "sorts the sources it lists, so a summary does not reshuffle itself" do
|
|
210
|
+
store.write_snapshot(un)
|
|
211
|
+
store.write_snapshot(ofac)
|
|
212
|
+
|
|
213
|
+
expect(store.sources).to eq(%i[ofac_sdn un_consolidated])
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
describe "#snapshot_meta" do
|
|
218
|
+
before { store.write_snapshot(ofac) }
|
|
219
|
+
|
|
220
|
+
it "reports what was stored without being asked for the list" do
|
|
221
|
+
expect(store.snapshot_meta(:ofac_sdn)).to eq(ActiveSanction::Storage::Meta.from_snapshot(ofac))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it "counts the records" do
|
|
225
|
+
expect(store.snapshot_meta(:ofac_sdn).record_count).to eq(2)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# What a match result cites (#33). A meta whose checksum is not the stored
|
|
229
|
+
# snapshot's cannot answer which list version cleared a customer.
|
|
230
|
+
it "carries the stored snapshot's checksum" do
|
|
231
|
+
expect(store.snapshot_meta(:ofac_sdn).checksum).to eq(ofac.checksum)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
describe "#each_entity" do
|
|
236
|
+
before do
|
|
237
|
+
store.write_snapshot(ofac)
|
|
238
|
+
store.write_snapshot(un)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "yields every record of every stored list" do
|
|
242
|
+
expect(store.each_entity.to_a.size).to eq(3)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
it "yields Entities" do
|
|
246
|
+
expect(store.each_entity.to_a).to all(be_an(ActiveSanction::Entity))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The index build (#31) walks every entity of every list. Handing it an
|
|
250
|
+
# array of ~25,000 records built before the first one is yielded is a cost
|
|
251
|
+
# nothing here needs to pay.
|
|
252
|
+
it "returns an Enumerator rather than an array when given no block" do
|
|
253
|
+
expect(store.each_entity).to be_an(Enumerator)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "stops when the caller stops" do
|
|
257
|
+
yielded = []
|
|
258
|
+
store.each_entity do |entity|
|
|
259
|
+
yielded << entity
|
|
260
|
+
break if yielded.any?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
expect(yielded.size).to eq(1)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "yields only the lists it was asked for" do
|
|
267
|
+
expect(entity_refs(store.each_entity(sources: %i[un_consolidated]).to_a)).to eq(%w[QDi.001])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "takes one source named on its own" do
|
|
271
|
+
expect(store.each_entity(sources: :un_consolidated).to_a.size).to eq(1)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Screening two of the three lists an application configured, and saying
|
|
275
|
+
# nothing about the third, is the failure this contract exists to prevent.
|
|
276
|
+
it "refuses to quietly skip a named list that was never synced" do
|
|
277
|
+
expect { store.each_entity(sources: %i[ofac_sdn eu_fsf]).to_a }
|
|
278
|
+
.to raise_error(ActiveSanction::Storage::MissingSnapshot)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it "yields nothing from an empty store" do
|
|
282
|
+
expect(build_store.each_entity.to_a).to be_empty
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
describe "removing a list" do
|
|
287
|
+
before { store.write_snapshot(ofac) }
|
|
288
|
+
|
|
289
|
+
it "reports that there was one to remove" do
|
|
290
|
+
expect(store.delete_snapshot(:ofac_sdn)).to be_truthy
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "forgets the snapshot" do
|
|
294
|
+
store.delete_snapshot(:ofac_sdn)
|
|
295
|
+
|
|
296
|
+
expect(store.read_snapshot(:ofac_sdn)).to be_nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it "stops listing the source" do
|
|
300
|
+
store.delete_snapshot(:ofac_sdn)
|
|
301
|
+
|
|
302
|
+
expect(store.sources).to be_empty
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "leaves the other lists alone" do
|
|
306
|
+
store.write_snapshot(un)
|
|
307
|
+
store.delete_snapshot(:ofac_sdn)
|
|
308
|
+
|
|
309
|
+
expect(store.read_snapshot(:un_consolidated)).to eq(un)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "empties the whole store on #clear" do
|
|
313
|
+
store.write_snapshot(un)
|
|
314
|
+
store.clear
|
|
315
|
+
|
|
316
|
+
expect(store).to be_empty
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# How the storage conformance group builds the adapter it is testing.
|
|
5
|
+
#
|
|
6
|
+
# In a module, and in a file of its own, for two reasons that are both about
|
|
7
|
+
# not defining a method twice. A group is free to override `build_store` in the
|
|
8
|
+
# customization block it passes to `it_behaves_like` -- which is how an adapter
|
|
9
|
+
# that cannot be built by `.new` alone gets held to the contract -- and
|
|
10
|
+
# defining a method over one the group already had would warn, since this suite
|
|
11
|
+
# runs with Ruby warnings on. Keeping it out of the shared example group's own
|
|
12
|
+
# file is the same rule one level up: the conformance spec loads that file
|
|
13
|
+
# again inside an RSpec sandbox, and a module reopened there would redefine
|
|
14
|
+
# whatever it holds.
|
|
15
|
+
#
|
|
16
|
+
# Namespaced, because it ships: a bare `StorageAdapterDefaults` at the top
|
|
17
|
+
# level would be this gem putting a name into a host's global namespace to
|
|
18
|
+
# save itself two words.
|
|
19
|
+
module ActiveSanction
|
|
20
|
+
module Testing
|
|
21
|
+
module StorageAdapterDefaults
|
|
22
|
+
def build_store = described_class.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
|
|
7
|
+
require "active_sanction"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
# The conformance groups, shipped, so that an adapter written outside this
|
|
11
|
+
# repository can prove it conforms.
|
|
12
|
+
#
|
|
13
|
+
# # spec/spec_helper.rb, in your application
|
|
14
|
+
# require "active_sanction/testing"
|
|
15
|
+
#
|
|
16
|
+
# # spec/internal_watchlist_spec.rb
|
|
17
|
+
# RSpec.describe MyCompany::InternalWatchlist do
|
|
18
|
+
# it_behaves_like "a sanction source", fixture: "internal_watchlist/list.csv"
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# RSpec.describe MyCompany::PostgresStore do
|
|
22
|
+
# it_behaves_like "a storage adapter" do
|
|
23
|
+
# def build_store = described_class.new(url: ENV.fetch("DATABASE_URL"))
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Two groups: **"a sanction source"** for a Sources::Base subclass, and
|
|
28
|
+
# **"a storage adapter"** for a Storage::Base implementation. Each is
|
|
29
|
+
# documented at the top of its own file.
|
|
30
|
+
#
|
|
31
|
+
# ### Why this is in `lib/` and not in `spec/`
|
|
32
|
+
#
|
|
33
|
+
# `docs/api_stability.md` names Sources::Base, Storage::Base and
|
|
34
|
+
# ValidatorStore as the three extension points carrying the strongest
|
|
35
|
+
# guarantee, and says of these groups that they "are the executable
|
|
36
|
+
# statement of what they require. An adapter that passes them today passes
|
|
37
|
+
# them for the life of the major version."
|
|
38
|
+
#
|
|
39
|
+
# That promise is addressed to people who install this gem, and until this
|
|
40
|
+
# file existed it was kept only for adapters living in this repository:
|
|
41
|
+
# `spec/` is excluded from the package, so the executable statement shipped
|
|
42
|
+
# nowhere and the guarantee was a paragraph again for everyone it was
|
|
43
|
+
# written for. A conformance suite that its audience cannot run is a
|
|
44
|
+
# conformance suite in name.
|
|
45
|
+
#
|
|
46
|
+
# ### Requiring it is opt-in, and costs a host nothing
|
|
47
|
+
#
|
|
48
|
+
# `require "active_sanction"` does not load this, and nothing under `lib/`
|
|
49
|
+
# requires it. It is loaded by a suite that asked for it, needs RSpec
|
|
50
|
+
# already loaded, and says so rather than failing somewhere stranger if it
|
|
51
|
+
# is not.
|
|
52
|
+
#
|
|
53
|
+
# ### What they do not do
|
|
54
|
+
#
|
|
55
|
+
# Neither group knows anything about *your* list. They are the floor: that
|
|
56
|
+
# an adapter declares what it must, produces Entities of the right shape,
|
|
57
|
+
# gives every record a stable id, and reports what it could not read. Only a
|
|
58
|
+
# spec that knows which of your fixture's records is a vessel can check that
|
|
59
|
+
# you read it correctly, and you still write that one.
|
|
60
|
+
module Testing
|
|
61
|
+
extend T::Sig
|
|
62
|
+
|
|
63
|
+
# Where "a sanction source" looks for the fixture a group names, relative
|
|
64
|
+
# to the working directory a suite runs from. `spec/fixtures` is where
|
|
65
|
+
# RSpec projects put them, which makes the default right for most callers
|
|
66
|
+
# and the setting below right for the rest.
|
|
67
|
+
DEFAULT_FIXTURE_ROOT = T.let("spec/fixtures", String)
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
extend T::Sig
|
|
71
|
+
|
|
72
|
+
# Where fixture paths are resolved from. Set it when a project keeps
|
|
73
|
+
# them somewhere other than `spec/fixtures`:
|
|
74
|
+
#
|
|
75
|
+
# ActiveSanction::Testing.fixture_root = "test/data/sanctions"
|
|
76
|
+
#
|
|
77
|
+
# Relative to the working directory, which for a suite is the project
|
|
78
|
+
# root. An absolute path passed to `fixture:` ignores this entirely.
|
|
79
|
+
sig { params(value: T.untyped).void }
|
|
80
|
+
def fixture_root=(value)
|
|
81
|
+
@fixture_root = T.let(value&.to_s, T.nilable(String))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns(String) }
|
|
85
|
+
def fixture_root
|
|
86
|
+
@fixture_root ||= T.let(nil, T.nilable(String))
|
|
87
|
+
File.expand_path(@fixture_root || DEFAULT_FIXTURE_ROOT, Dir.pwd)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One fixture, as an absolute path. An absolute path is returned as it
|
|
91
|
+
# stands, so a project that generates a fixture into a temporary
|
|
92
|
+
# directory can name it directly.
|
|
93
|
+
#
|
|
94
|
+
# Missing files are refused here rather than at `File.binread`, because
|
|
95
|
+
# a conformance run against a fixture that is not there should say which
|
|
96
|
+
# fixture and where it looked -- the two facts needed to fix it.
|
|
97
|
+
sig { params(path: T.untyped).returns(String) }
|
|
98
|
+
def fixture_path(path)
|
|
99
|
+
name = path.to_s
|
|
100
|
+
resolved = Pathname.new(name).absolute? ? name : File.expand_path(name, fixture_root)
|
|
101
|
+
return resolved if File.file?(resolved)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"no fixture at #{resolved}. Paths are resolved under #{fixture_root} -- " \
|
|
105
|
+
"set ActiveSanction::Testing.fixture_root if yours live somewhere else."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
unless defined?(RSpec)
|
|
112
|
+
raise LoadError,
|
|
113
|
+
"active_sanction/testing defines RSpec shared example groups, and RSpec is not loaded. " \
|
|
114
|
+
"Require it from your spec_helper, after rspec itself."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
require "active_sanction/testing/storage_adapter_defaults"
|
|
118
|
+
require "active_sanction/testing/sanction_source"
|
|
119
|
+
require "active_sanction/testing/storage_adapter"
|
|
@@ -6,7 +6,7 @@ module ActiveSanction
|
|
|
6
6
|
# new source adapter, a storage fix or a documentation release -- none of
|
|
7
7
|
# which change what a name scores. MATCHER_VERSION, below, is the one that
|
|
8
8
|
# answers that question.
|
|
9
|
-
VERSION = "1.
|
|
9
|
+
VERSION = "1.1.1"
|
|
10
10
|
|
|
11
11
|
# Which matching pipeline scored a decision, stamped onto every MatchResult
|
|
12
12
|
# and bumped whenever a change to the normalizer, the index, the similarity
|
data/lib/active_sanction.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "sorbet-runtime"
|
|
|
6
6
|
require "active_sanction/error"
|
|
7
7
|
require "active_sanction/version"
|
|
8
8
|
require "active_sanction/deprecation"
|
|
9
|
+
require "active_sanction/instrumentation"
|
|
9
10
|
require "active_sanction/configuration"
|
|
10
11
|
require "active_sanction/name"
|
|
11
12
|
require "active_sanction/address"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_sanction
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marshall Shen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: csv
|
|
@@ -102,6 +102,9 @@ files:
|
|
|
102
102
|
- lib/active_sanction/index/candidate.rb
|
|
103
103
|
- lib/active_sanction/index/entry.rb
|
|
104
104
|
- lib/active_sanction/index/features.rb
|
|
105
|
+
- lib/active_sanction/instrumentation.rb
|
|
106
|
+
- lib/active_sanction/instrumentation/event.rb
|
|
107
|
+
- lib/active_sanction/instrumentation/notifications.rb
|
|
105
108
|
- lib/active_sanction/match_result.rb
|
|
106
109
|
- lib/active_sanction/matcher.rb
|
|
107
110
|
- lib/active_sanction/name.rb
|
|
@@ -198,6 +201,10 @@ files:
|
|
|
198
201
|
- lib/active_sanction/sync.rb
|
|
199
202
|
- lib/active_sanction/sync/report.rb
|
|
200
203
|
- lib/active_sanction/sync/result.rb
|
|
204
|
+
- lib/active_sanction/testing.rb
|
|
205
|
+
- lib/active_sanction/testing/sanction_source.rb
|
|
206
|
+
- lib/active_sanction/testing/storage_adapter.rb
|
|
207
|
+
- lib/active_sanction/testing/storage_adapter_defaults.rb
|
|
201
208
|
- lib/active_sanction/validator_store.rb
|
|
202
209
|
- lib/active_sanction/validator_store/file_system.rb
|
|
203
210
|
- lib/active_sanction/validator_store/memory.rb
|