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,412 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/entity"
|
|
7
|
+
require "active_sanction/name"
|
|
8
|
+
require "active_sanction/address"
|
|
9
|
+
require "active_sanction/identifier"
|
|
10
|
+
require "active_sanction/partial_date"
|
|
11
|
+
require "active_sanction/sources/remarks"
|
|
12
|
+
|
|
13
|
+
module ActiveSanction
|
|
14
|
+
module Sources
|
|
15
|
+
class UkSanctionsList < Base
|
|
16
|
+
# One `<Designation>` turned into an Entity.
|
|
17
|
+
#
|
|
18
|
+
# Separate from the adapter because they are two jobs: the adapter says
|
|
19
|
+
# what the list is and where it lives, and this says what the FCDO's
|
|
20
|
+
# elements mean. The mapping is where all the judgment sits, so it is
|
|
21
|
+
# worth being able to read it on its own.
|
|
22
|
+
#
|
|
23
|
+
# ### A name is six numbered parts, in order
|
|
24
|
+
#
|
|
25
|
+
# The FCDO splits a name across `<Name1>` to `<Name6>`: given names
|
|
26
|
+
# ascending, and `<Name6>` holding the family name -- or, for an
|
|
27
|
+
# organization or a ship, the whole name on its own, which is why
|
|
28
|
+
# `<Name6>` is populated on 15,647 of the 15,677 names and `<Name1>` on
|
|
29
|
+
# 9,691. Joined in numeric order they read the way the name is said:
|
|
30
|
+
# `ABDUL KABIR MUHAMMAD JAN`, `ABDUL SALAM HANAFI ALI MARDAN QUL`.
|
|
31
|
+
#
|
|
32
|
+
# Note that the CSV rendering of this same list orders its columns
|
|
33
|
+
# `Name 6, Name 1, Name 2, ...`, which is the FCDO filing surname-first
|
|
34
|
+
# for a reader and not a different name. Following that order here would
|
|
35
|
+
# produce `JAN ABDUL KABIR MUHAMMAD`, which is a string no query will
|
|
36
|
+
# match and no publisher wrote.
|
|
37
|
+
#
|
|
38
|
+
# ### `Primary Name Variation` is an alias, and 5,513 names depend on it
|
|
39
|
+
# not being treated as one more primary
|
|
40
|
+
#
|
|
41
|
+
# The FCDO publishes three name types. `Primary Name` is the designation
|
|
42
|
+
# proper. `Alias` is a different name the person is known by. `Primary
|
|
43
|
+
# Name Variation` is a different *spelling* of the primary name -- the
|
|
44
|
+
# four rows `MITHOO`, `MITHU`, `MITTO`, `MITTU` against one Pakistani
|
|
45
|
+
# cleric -- and it is by far the most common thing on the list after the
|
|
46
|
+
# aliases themselves. All of them are real screening signal and none of
|
|
47
|
+
# them is the official name, so a variation is filed as `:aka`, which is
|
|
48
|
+
# what leaves `Entity#primary_name` answering with the name the FCDO
|
|
49
|
+
# actually designated.
|
|
50
|
+
#
|
|
51
|
+
# @api private
|
|
52
|
+
class Record
|
|
53
|
+
extend T::Sig
|
|
54
|
+
|
|
55
|
+
TYPES = T.let(
|
|
56
|
+
{ "individual" => :individual, "entity" => :organization, "ship" => :vessel }.freeze,
|
|
57
|
+
T::Hash[String, Symbol]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# `NameType`, case-folded. The list contains `Primary Name`, `Primary
|
|
61
|
+
# name`, `Primary Name Variation`, `Primary name variation`, `Alias`
|
|
62
|
+
# and one `ALias`, which is the whole reason this is a folded lookup
|
|
63
|
+
# rather than a comparison.
|
|
64
|
+
NAME_KINDS = T.let(
|
|
65
|
+
{ "primary name" => :primary, "primary name variation" => :aka, "alias" => :aka }.freeze,
|
|
66
|
+
T::Hash[String, Symbol]
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# `AliasStrength`, which the FCDO publishes as exactly two strings on
|
|
70
|
+
# 2,073 names and leaves absent on the rest. Absent is nil rather than
|
|
71
|
+
# good: an ungraded name must not be penalized for a field the FCDO
|
|
72
|
+
# did not fill in, and must not be credited for one either.
|
|
73
|
+
ALIAS_QUALITIES = T.let(
|
|
74
|
+
{ "good quality a.k.a" => :good, "low quality a.k.a" => :low }.freeze,
|
|
75
|
+
T::Hash[String, Symbol]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Name parts, in the order they are said.
|
|
79
|
+
NAME_PARTS = T.let((1..6).map { |part| "Name#{part}" }.freeze, T::Array[String])
|
|
80
|
+
|
|
81
|
+
# Address lines 1 to 5 are the street and the locality detail; line 6
|
|
82
|
+
# is the last line the FCDO writes before the country. See #address.
|
|
83
|
+
STREET_LINES = T.let((1..5).map { |line| "AddressLine#{line}" }.freeze, T::Array[String])
|
|
84
|
+
|
|
85
|
+
# 635 of the 670 IMO numbers are published as `IMO9562233` and the
|
|
86
|
+
# other 35 as `9562233`. The prefix restates the element the number is
|
|
87
|
+
# already inside, and Identifier compares on alphanumerics -- so left
|
|
88
|
+
# alone the FCDO's own two spellings of one registry number are two
|
|
89
|
+
# different identifiers, and a query for either finds one of them. It
|
|
90
|
+
# is peeled off into the note, where it is still readable and no longer
|
|
91
|
+
# part of what gets compared.
|
|
92
|
+
IMO_PREFIX = T.let(/\AIMO[[:space:]]*(?=\d)/i, Regexp)
|
|
93
|
+
|
|
94
|
+
# The FCDO's line wrapping arrives inside element text -- a statement
|
|
95
|
+
# of reasons opens and closes on a newline -- and a name that keeps it
|
|
96
|
+
# is a name nothing will ever match.
|
|
97
|
+
WHITESPACE = T.let(/[[:space:]]+/, Regexp)
|
|
98
|
+
|
|
99
|
+
# What separates the two prose fields once they are joined. See
|
|
100
|
+
# #published_prose.
|
|
101
|
+
PARAGRAPH = T.let("\n\n", String)
|
|
102
|
+
|
|
103
|
+
sig { returns(Parsers::XmlRecords::Record) }
|
|
104
|
+
attr_reader :node
|
|
105
|
+
|
|
106
|
+
sig { params(node: Parsers::XmlRecords::Record).void }
|
|
107
|
+
def initialize(node)
|
|
108
|
+
@node = T.let(node, Parsers::XmlRecords::Record)
|
|
109
|
+
@names = T.let(nil, T.nilable(T::Array[Name]))
|
|
110
|
+
@dates_of_birth = T.let(nil, T.nilable(T::Array[PartialDate]))
|
|
111
|
+
@unread_dates = T.let([], T::Array[String])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The entity, or nil for a record with no name -- which cannot be
|
|
115
|
+
# screened against and is never what the FCDO meant to publish.
|
|
116
|
+
sig { returns(T.nilable(Entity)) }
|
|
117
|
+
def entity
|
|
118
|
+
return nil if names.empty?
|
|
119
|
+
|
|
120
|
+
Entity.new(source: :uk_sanctions_list, source_ref: source_ref, type: type, names: names,
|
|
121
|
+
addresses: addresses, identifiers: identifiers, dates_of_birth: dates_of_birth,
|
|
122
|
+
nationalities: nationalities, programs: programs, listed_on: listed_on,
|
|
123
|
+
remarks: remarks)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `IndividualEntityShip`, which is populated on every record and is the
|
|
127
|
+
# only place the list says what a designation is about. A ship is a
|
|
128
|
+
# `:vessel`, and there are 664 of them: without the distinct type a
|
|
129
|
+
# search for a person can rank a tanker.
|
|
130
|
+
sig { returns(Symbol) }
|
|
131
|
+
def type = TYPES.fetch(node["IndividualEntityShip"].to_s.downcase, :organization)
|
|
132
|
+
|
|
133
|
+
# `UniqueID` -- `AFG0001`, `RUS3379` -- rather than the OFSI Group ID
|
|
134
|
+
# beside it. Both are unique where they are present, but the Group ID
|
|
135
|
+
# is being retired: designations made since 28 January 2026 do not get
|
|
136
|
+
# one, so an id built from it would be nil on everything the UK has
|
|
137
|
+
# sanctioned this year. The historic Group ID is kept in remarks.
|
|
138
|
+
sig { returns(T.nilable(String)) }
|
|
139
|
+
def source_ref = node["UniqueID"]
|
|
140
|
+
|
|
141
|
+
# Prose, the way the UN publishes it: `Russia`, `North Korea`,
|
|
142
|
+
# `Congo (Democratic Republic)`. Country resolves it to an ISO code at
|
|
143
|
+
# scoring time, and treats one it cannot resolve as absent rather than
|
|
144
|
+
# as a conflict -- which is the answer that matters for `Kosovo`, a
|
|
145
|
+
# nationality on ten of these records and a country with no ISO 3166-1
|
|
146
|
+
# code to resolve to.
|
|
147
|
+
sig { returns(T::Array[String]) }
|
|
148
|
+
def nationalities = node.values("IndividualDetails/Individual/Nationalities/Nationality").uniq
|
|
149
|
+
|
|
150
|
+
# The date the FCDO designated the entity, on all 6,334 records.
|
|
151
|
+
sig { returns(T.nilable(PartialDate)) }
|
|
152
|
+
def listed_on = PublishedDate.call(node["DateDesignated"])
|
|
153
|
+
|
|
154
|
+
# The regime the designation is made under, which for this list is the
|
|
155
|
+
# statutory instrument itself: `The Russia (Sanctions) (EU Exit)
|
|
156
|
+
# Regulations 2019`. 31 of them cover the whole list, and it is the
|
|
157
|
+
# closest thing the UK publishes to OFAC's programme codes -- the
|
|
158
|
+
# measures actually imposed are a separate field, and are in remarks.
|
|
159
|
+
sig { returns(T::Array[String]) }
|
|
160
|
+
def programs = node.values("RegimeName").uniq
|
|
161
|
+
|
|
162
|
+
sig { returns(T::Array[Name]) }
|
|
163
|
+
def names
|
|
164
|
+
@names ||= dedupe(published_names + non_latin_names)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Memoized, because reading them is also what fills the list of dates
|
|
168
|
+
# this could not use, which #remarks then keeps rather than losing.
|
|
169
|
+
sig { returns(T::Array[PartialDate]) }
|
|
170
|
+
def dates_of_birth
|
|
171
|
+
@dates_of_birth ||= begin
|
|
172
|
+
@unread_dates = []
|
|
173
|
+
node.values("IndividualDetails/Individual/DOBs/DOB").filter_map { |born| date_of_birth(born) }.uniq
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Passports and national identity numbers for a person, business
|
|
178
|
+
# registration numbers for an organization, IMO numbers for a ship.
|
|
179
|
+
# De-duplicated through Identifier's own equality, which compares a
|
|
180
|
+
# number by its alphanumerics rather than by the punctuation around
|
|
181
|
+
# them: the FCDO's export repeats a passport once per birth date, so
|
|
182
|
+
# one Afghan record publishes the same number ten times.
|
|
183
|
+
sig { returns(T::Array[Identifier]) }
|
|
184
|
+
def identifiers = (passports + national_ids + registrations + imo_numbers).uniq
|
|
185
|
+
|
|
186
|
+
sig { returns(T::Array[Address]) }
|
|
187
|
+
def addresses
|
|
188
|
+
node.nodes("Addresses/Address").filter_map { |place| address(place) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The FCDO's own prose verbatim -- its note on the designation and its
|
|
192
|
+
# statement of reasons, which are two fields and one voice -- then the
|
|
193
|
+
# elements that have nowhere else to go, behind the marker that makes
|
|
194
|
+
# them trivial to strip again.
|
|
195
|
+
sig { returns(T.nilable(String)) }
|
|
196
|
+
def remarks
|
|
197
|
+
dates_of_birth # for its side effect: it is what fills @unread_dates, which #extras keeps
|
|
198
|
+
Remarks.build(published_prose, extras)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
# The two fields the FCDO writes prose in: its note on the designation,
|
|
204
|
+
# and the legal case for it. Both are the publisher's own words and one
|
|
205
|
+
# voice, so both are kept ahead of the marker -- what a consumer asking
|
|
206
|
+
# what the FCDO said gets back should not depend on which of its two
|
|
207
|
+
# boxes an examiner happened to type in.
|
|
208
|
+
# nil for the 127 records the FCDO wrote neither field on, so that a
|
|
209
|
+
# consumer asking what the publisher said is handed nothing rather than
|
|
210
|
+
# a list of fields this adapter appended.
|
|
211
|
+
sig { returns(T.nilable(String)) }
|
|
212
|
+
def published_prose
|
|
213
|
+
prose = [node["OtherInformation"], node["UKStatementofReasons"]].compact.map(&:strip).reject(&:empty?)
|
|
214
|
+
prose.empty? ? nil : prose.join(PARAGRAPH)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Every `<Name>` the record carries, in document order. 26 of the
|
|
218
|
+
# 15,677 published carry a `NameType` and no part at all, and a
|
|
219
|
+
# blank-valued Name is a record that matches everything.
|
|
220
|
+
sig { returns(T::Array[Name]) }
|
|
221
|
+
def published_names
|
|
222
|
+
node.nodes("Names/Name").filter_map do |published|
|
|
223
|
+
value = collapse(NAME_PARTS.filter_map { |part| published[part] }.join(" "))
|
|
224
|
+
next nil if value.nil?
|
|
225
|
+
|
|
226
|
+
Name.new(value: value, kind: name_kind(published), quality: alias_quality(published))
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The name in the script the FCDO's source wrote it in -- Cyrillic for
|
|
231
|
+
# 1,752 of them, Arabic for 179. Filed as an alias with no `script`
|
|
232
|
+
# declared; the adapter's class comment says why the FCDO's own label
|
|
233
|
+
# is not used to declare one.
|
|
234
|
+
sig { returns(T::Array[Name]) }
|
|
235
|
+
def non_latin_names
|
|
236
|
+
node.values("NonLatinNames/NonLatinName/NameNonLatinScript").filter_map do |published|
|
|
237
|
+
value = collapse(published)
|
|
238
|
+
value && Name.new(value: value, kind: :aka)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
sig { params(published: Parsers::XmlRecords::Record).returns(Symbol) }
|
|
243
|
+
def name_kind(published) = NAME_KINDS.fetch(published["NameType"].to_s.strip.downcase, :aka)
|
|
244
|
+
|
|
245
|
+
sig { params(published: Parsers::XmlRecords::Record).returns(T.nilable(Symbol)) }
|
|
246
|
+
def alias_quality(published) = ALIAS_QUALITIES[published["AliasStrength"].to_s.strip.downcase]
|
|
247
|
+
|
|
248
|
+
# 36 names across 35 records repeat a spelling already on the record,
|
|
249
|
+
# usually because the same string is filed once as a variation and once
|
|
250
|
+
# as an alias. Two identical Names are two index entries that can only
|
|
251
|
+
# ever fire together. The first wins, so a name published as primary
|
|
252
|
+
# keeps its kind.
|
|
253
|
+
sig { params(published: T::Array[Name]).returns(T::Array[Name]) }
|
|
254
|
+
def dedupe(published)
|
|
255
|
+
seen = T.let({}, T::Hash[String, Name])
|
|
256
|
+
published.each { |name| seen[name.value] ||= name }
|
|
257
|
+
seen.values
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
sig { params(born: String).returns(T.nilable(PartialDate)) }
|
|
261
|
+
def date_of_birth(born)
|
|
262
|
+
PublishedDate.call(born) || note_unread(born)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A birth date string that gave no usable date -- today, the one record
|
|
266
|
+
# published as `15/08/19yy`. Kept so that nothing the FCDO said about
|
|
267
|
+
# when a person was born disappears silently.
|
|
268
|
+
sig { params(born: String).returns(NilClass) }
|
|
269
|
+
def note_unread(born)
|
|
270
|
+
@unread_dates << born
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
sig { returns(T::Array[Identifier]) }
|
|
275
|
+
def passports
|
|
276
|
+
documents("IndividualDetails/Individual/PassportDetails/Passport", :passport,
|
|
277
|
+
"PassportNumber", "PassportAdditionalInformation")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
sig { returns(T::Array[Identifier]) }
|
|
281
|
+
def national_ids
|
|
282
|
+
documents("IndividualDetails/Individual/NationalIdentifierDetails/NationalIdentifier", :national_id,
|
|
283
|
+
"NationalIdentifierNumber", "NationalIdentifierAdditionalInformation")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
sig { params(path: String, kind: Symbol, number: String, information: String).returns(T::Array[Identifier]) }
|
|
287
|
+
def documents(path, kind, number, information)
|
|
288
|
+
node.nodes(path).filter_map do |document|
|
|
289
|
+
identifier(kind: kind, value: document[number], note: document[information])
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Kept exactly as published, prefixes and all. 340 of the 721 open with
|
|
294
|
+
# a label -- `INN: 7710137066`, `OGRN: 1247700291200` -- and a further
|
|
295
|
+
# handful carry two numbers, a country and a newline in one field. A
|
|
296
|
+
# rule that peeled the label off `INN: 7710137066` would have to decide
|
|
297
|
+
# what to do with `OGRN: 1247700291200\nKPP: 770701001\nINN:
|
|
298
|
+
# 9707028663`, and every answer to that is a guess about free text.
|
|
299
|
+
sig { returns(T::Array[Identifier]) }
|
|
300
|
+
def registrations
|
|
301
|
+
node.values("EntityDetails/Entity/BusinessRegistrationNumbers/BusinessRegistrationNumber")
|
|
302
|
+
.filter_map { |number| identifier(kind: :registration_number, value: number) }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Filed as :other because a ship's registry number is none of the
|
|
306
|
+
# document kinds the model names, and it is a document rather than a
|
|
307
|
+
# remark: an IMO number is unique, permanent and the one field on a
|
|
308
|
+
# vessel record that a screening query can be decisive about.
|
|
309
|
+
sig { returns(T::Array[Identifier]) }
|
|
310
|
+
def imo_numbers
|
|
311
|
+
node.values("ShipDetails/Ship/IMONumbers/IMONumber").filter_map do |number|
|
|
312
|
+
identifier(kind: :other, value: number.sub(IMO_PREFIX, ""), note: "IMO number")
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# 8 of the 780 passport elements carry additional information and no
|
|
317
|
+
# number. Identifier refuses a value with no alphanumerics, and those
|
|
318
|
+
# are dropped: a document with a description and no identity is nothing
|
|
319
|
+
# to match on, which is the same call the UN and EU adapters make.
|
|
320
|
+
sig { params(kind: Symbol, value: T.untyped, note: T.untyped).returns(T.nilable(Identifier)) }
|
|
321
|
+
def identifier(kind:, value:, note: nil)
|
|
322
|
+
Identifier.new(kind: kind, value: value, note: note)
|
|
323
|
+
rescue ArgumentError
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# `AddressLine1` to `AddressLine5` are the street and the locality
|
|
328
|
+
# detail, in order, and `AddressLine6` is the last line before the
|
|
329
|
+
# country. Line 6 is filed under `city` whole rather than split on a
|
|
330
|
+
# guess: it holds `Kabul` and `Dubai` on some records and `Helmand
|
|
331
|
+
# Province` and `Nimroz Province` on others, and a rule that tried to
|
|
332
|
+
# tell those apart would be wrong on both. 450 of the 3,816 addresses
|
|
333
|
+
# have line 6 as their only line.
|
|
334
|
+
sig { params(place: Parsers::XmlRecords::Record).returns(T.nilable(Address)) }
|
|
335
|
+
def address(place)
|
|
336
|
+
Address.new(street: join(STREET_LINES.filter_map { |line| place[line] }),
|
|
337
|
+
city: place["AddressLine6"], postal_code: place["AddressPostalCode"],
|
|
338
|
+
country: place["AddressCountry"])
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Label/value pairs for Remarks.build, which drops the ones the record
|
|
344
|
+
# left blank. A value may be several -- one person's positions are
|
|
345
|
+
# filed one per element -- and arrives as the Array it published.
|
|
346
|
+
sig { returns(T::Array[T.untyped]) }
|
|
347
|
+
def extras
|
|
348
|
+
[["OFSI group id", node["OFSIGroupID"]],
|
|
349
|
+
["UN reference", node["UNReferenceNumber"]],
|
|
350
|
+
["Designation source", node["DesignationSource"]],
|
|
351
|
+
["Sanctions imposed", node["SanctionsImposed"]],
|
|
352
|
+
["Last updated", node["LastUpdated"]],
|
|
353
|
+
["Title", node.values("Titles/Title")],
|
|
354
|
+
["Date of birth as published", @unread_dates]] + individual + organization + ship + contact
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
sig { returns(T::Array[T.untyped]) }
|
|
358
|
+
def individual
|
|
359
|
+
[["Gender", node.values("IndividualDetails/Individual/Genders/Gender").uniq],
|
|
360
|
+
["Position", node.values("IndividualDetails/Individual/Positions/Position")],
|
|
361
|
+
["Town of birth", node.values("IndividualDetails/Individual/BirthDetails/Location/TownOfBirth").uniq],
|
|
362
|
+
["Country of birth", node.values("IndividualDetails/Individual/BirthDetails/Location/CountryOfBirth").uniq]]
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
sig { returns(T::Array[T.untyped]) }
|
|
366
|
+
def organization
|
|
367
|
+
[["Type of entity", node.values("EntityDetails/Entity/TypeOfEntities/TypeOfEntity").uniq],
|
|
368
|
+
["Parent company", node.values("EntityDetails/Entity/ParentCompanies/ParentCompany")],
|
|
369
|
+
["Subsidiary", node.values("EntityDetails/Entity/Subsidiaries/Subsidiary")]]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# A ship's flag, owner and dimensions are real screening signal with no
|
|
373
|
+
# home in the canonical model -- the same trade OFAC's adapter makes
|
|
374
|
+
# for a vessel's tonnage and owner -- so they are kept here rather than
|
|
375
|
+
# dropped to keep a schema tidy.
|
|
376
|
+
sig { returns(T::Array[T.untyped]) }
|
|
377
|
+
def ship
|
|
378
|
+
{ "Current owner/operator" => "CurrentOwnerOperators/CurrentOwnerOperator",
|
|
379
|
+
"Previous owner/operator" => "PreviousOwnerOperators/PreviousOwnerOperator",
|
|
380
|
+
"Current believed flag" => "CurrentBelievedFlagOfShips/CurrentBelievedFlagOfShip",
|
|
381
|
+
"Previous flag" => "PreviousFlags/PreviousFlag",
|
|
382
|
+
"Type of ship" => "TypeOfShipDetails/TypeOfShip",
|
|
383
|
+
"Tonnage" => "TonnageOfShipDetails/TonnageOfShip",
|
|
384
|
+
"Length" => "LengthOfShipDetails/LengthOfShip",
|
|
385
|
+
"Year built" => "YearsBuilt/YearBuilt" }
|
|
386
|
+
.map { |label, path| [label, node.values("ShipDetails/Ship/#{path}")] }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Contact details, and the FCDO's own reading of the non-Latin names.
|
|
390
|
+
# The script label is recorded rather than declared on the Name -- see
|
|
391
|
+
# the adapter's class comment for the three records that say why.
|
|
392
|
+
sig { returns(T::Array[T.untyped]) }
|
|
393
|
+
def contact
|
|
394
|
+
[["Phone", node.values("PhoneNumbers/PhoneNumber")],
|
|
395
|
+
["Website", node.values("Websites/Website")],
|
|
396
|
+
["Email", node.values("EmailAddresses/EmailAddress")],
|
|
397
|
+
["Non-Latin script", node.values("NonLatinNames/NonLatinName/NonLatinScriptType").uniq],
|
|
398
|
+
["Non-Latin language", node.values("NonLatinNames/NonLatinName/NonLatinScriptLanguage").uniq]]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
sig { params(parts: T::Array[String]).returns(T.nilable(String)) }
|
|
402
|
+
def join(parts) = parts.empty? ? nil : parts.join(", ")
|
|
403
|
+
|
|
404
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
405
|
+
def collapse(value)
|
|
406
|
+
string = value.to_s.split(WHITESPACE).join(" ")
|
|
407
|
+
string.empty? ? nil : -string
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/parsers"
|
|
7
|
+
require "active_sanction/sources"
|
|
8
|
+
require "active_sanction/sources/base"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
module Sources
|
|
12
|
+
# The UK Sanctions List: every designation made under the Sanctions and
|
|
13
|
+
# Anti-Money Laundering Act 2018, published by the Foreign, Commonwealth
|
|
14
|
+
# and Development Office.
|
|
15
|
+
#
|
|
16
|
+
# snapshot = ActiveSanction::Sources[:uk_sanctions_list].new.sync
|
|
17
|
+
#
|
|
18
|
+
# ### This is not the OFSI Consolidated List, and that is deliberate
|
|
19
|
+
#
|
|
20
|
+
# The list this adapter was scoped against -- OFSI's Consolidated List of
|
|
21
|
+
# Asset Freeze Targets, `ConList.csv` -- was retired on 28 January 2026,
|
|
22
|
+
# when the UK moved every sanctions designation onto one list. Its gov.uk
|
|
23
|
+
# page is marked withdrawn, and the FCDO's own transition guidance says the
|
|
24
|
+
# Consolidated List "is no longer being updated".
|
|
25
|
+
#
|
|
26
|
+
# The blob it was served from still answers 200. That is the trap: an
|
|
27
|
+
# adapter reading it would look healthy on every sync, download a real
|
|
28
|
+
# file, produce real entities and screen a book of business against a list
|
|
29
|
+
# that stopped moving in January. A stale list that reports itself fresh is
|
|
30
|
+
# the most expensive way this library can fail, so the UK adapter reads the
|
|
31
|
+
# list the UK actually publishes.
|
|
32
|
+
#
|
|
33
|
+
# OFSI Group IDs are not lost in the move: the FCDO carries the historic
|
|
34
|
+
# one on every designation made before 28 January 2026, and this adapter
|
|
35
|
+
# keeps it in `remarks` so a hit can still be reconciled against an OFSI
|
|
36
|
+
# licence application or a suspected-breach report. Designations made since
|
|
37
|
+
# carry a `UniqueID` and nothing else, which is why the Unique ID is what
|
|
38
|
+
# `source_ref` is built from.
|
|
39
|
+
#
|
|
40
|
+
# ### Seven formats, and why this one
|
|
41
|
+
#
|
|
42
|
+
# https://sanctionslist.fcdo.gov.uk/docs/UK-Sanctions-List.xml
|
|
43
|
+
#
|
|
44
|
+
# <Designations><Designation> 6,334 records, 21.8 MB
|
|
45
|
+
#
|
|
46
|
+
# The FCDO publishes the same list seven ways, and two of them are
|
|
47
|
+
# machine-readable at this size: the XML above and a CSV added in January
|
|
48
|
+
# to match the format OFSI's readers had been built against. The CSV is
|
|
49
|
+
# 49.9 MB and 58,424 rows for the same 6,334 designations, because it is a
|
|
50
|
+
# full cartesian product of every repeating group -- one record in it,
|
|
51
|
+
# `INU0075`, occupies 3,780 rows, being 10 names x 7 addresses x 3 phone
|
|
52
|
+
# numbers x 2 websites x 9 subsidiaries. Reading it means grouping 58,424
|
|
53
|
+
# rows and then de-duplicating each dimension back out of the product,
|
|
54
|
+
# which is recoverable but is reconstruction rather than parsing. The XML
|
|
55
|
+
# states the same structure directly, in 44% of the bytes, against a
|
|
56
|
+
# published XSD, and it carries a field the CSV has no column for at all.
|
|
57
|
+
#
|
|
58
|
+
# ### Conditional GET works here
|
|
59
|
+
#
|
|
60
|
+
# Unlike the EU's endpoint, this one serves both an ETag and a
|
|
61
|
+
# Last-Modified and honours both: a sync against an unchanged list answers
|
|
62
|
+
# 304 and downloads nothing. Verified against the live endpoint. So the
|
|
63
|
+
# usual run of this source costs one request, and the 21.8 MB is paid only
|
|
64
|
+
# on the days the FCDO republishes.
|
|
65
|
+
#
|
|
66
|
+
# ### The two traps in this list
|
|
67
|
+
#
|
|
68
|
+
# **A date component the FCDO does not know is spelled out, not omitted.**
|
|
69
|
+
# `dd/mm/1962` is a year-only birth date, `dd/07/1978` is a month, and
|
|
70
|
+
# `00/00/1975` is one more spelling of the same thing. 824 of the 3,788
|
|
71
|
+
# published birth dates -- 22% -- carry a placeholder, and every one of
|
|
72
|
+
# them reads as nil through an ordinary date parser. See PublishedDate.
|
|
73
|
+
#
|
|
74
|
+
# **The publisher's own script labels disagree with its own strings.**
|
|
75
|
+
# `NonLatinScriptType` is stated on 2,057 of the 3,856 non-Latin names and
|
|
76
|
+
# agrees with the characters on 2,054 of them -- but three names labelled
|
|
77
|
+
# `Cyrillic` are Latin transliterations (`OAO "STUPINSKAYA
|
|
78
|
+
# METALLURGICHESKAYA KOMPANIYA"`), and 185 names filed under
|
|
79
|
+
# `<NonLatinName>` contain no non-Latin character at all. So `script` is
|
|
80
|
+
# left unstated on every name here, which is the same call the UN and EU
|
|
81
|
+
# adapters make for the same reason: which script a string is in is a
|
|
82
|
+
# question about its characters, and the normalizer is where that gets
|
|
83
|
+
# answered. The FCDO's label and language are kept in `remarks`.
|
|
84
|
+
#
|
|
85
|
+
# ### What a clean UK result is worth
|
|
86
|
+
#
|
|
87
|
+
# More than an EU one. The FCDO marks its own primary name -- every one of
|
|
88
|
+
# the 6,334 records carries exactly one `NameType` of `Primary Name`, six
|
|
89
|
+
# carry two, and none carries none -- so this adapter never has to guess
|
|
90
|
+
# which spelling is the official one, which is the judgment the EU adapter
|
|
91
|
+
# is stuck making. Alias grading is published as a field rather than as
|
|
92
|
+
# prose, on 2,073 names. Against that, nationality arrives as prose
|
|
93
|
+
# ("Russia", "North Korea"), the way the UN publishes it, and is resolved
|
|
94
|
+
# by Country at scoring time rather than here.
|
|
95
|
+
#
|
|
96
|
+
# ### What this adapter does not do
|
|
97
|
+
#
|
|
98
|
+
# It does not read `<CryptoWalletAddresses>` or
|
|
99
|
+
# `<HullIdentificationNumbers>`. The XSD defines both and the FCDO populates
|
|
100
|
+
# neither on any of the 6,334 records published today, and a mapping
|
|
101
|
+
# written against zero records is a guess that gets discovered to be wrong
|
|
102
|
+
# by a user rather than by a test. They are the first things to add when
|
|
103
|
+
# either field appears.
|
|
104
|
+
#
|
|
105
|
+
# It does not parse `PassportAdditionalInformation`, which is a sentence
|
|
106
|
+
# ("Afghanistan passport number P04581926, issued on 7 August 2024, issued
|
|
107
|
+
# in Kandahar, Afghanistan (expires 7 August 2034)") carrying an issuing
|
|
108
|
+
# country and two dates that Identifier has members for. Reading it is the
|
|
109
|
+
# same job OFAC's RemarksParser does and wants the same treatment --
|
|
110
|
+
# a measured coverage figure -- rather than a regex added here in passing.
|
|
111
|
+
# The sentence is kept verbatim on the identifier's note.
|
|
112
|
+
class UkSanctionsList < Base
|
|
113
|
+
extend T::Sig
|
|
114
|
+
|
|
115
|
+
key :uk_sanctions_list
|
|
116
|
+
jurisdiction :uk
|
|
117
|
+
authority "Foreign, Commonwealth and Development Office"
|
|
118
|
+
format :xml
|
|
119
|
+
|
|
120
|
+
# Crown copyright under the Open Government Licence v3.0, which is the
|
|
121
|
+
# most permissive of the seven: reuse for any purpose, commercial
|
|
122
|
+
# included, on an attribution condition.
|
|
123
|
+
licence_notice "Crown copyright, reusable under the Open Government " \
|
|
124
|
+
"Licence v3.0 with attribution. Verified 2026-09-11."
|
|
125
|
+
licence_url "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
|
126
|
+
|
|
127
|
+
# Static since January 2026, and the FCDO says so out loud: the URL for
|
|
128
|
+
# each format stays the same however many times the list is refreshed.
|
|
129
|
+
url :main, "https://sanctionslist.fcdo.gov.uk/docs/UK-Sanctions-List.xml"
|
|
130
|
+
|
|
131
|
+
# @api private
|
|
132
|
+
DESIGNATION = T.let("Designation", String)
|
|
133
|
+
|
|
134
|
+
# The FCDO's own generation date, which it publishes as an element
|
|
135
|
+
# sibling of the records rather than as an attribute of the document.
|
|
136
|
+
# Named as a record so that the one pass over the payload reads it on the
|
|
137
|
+
# way past -- it is the first child of `<Designations>` -- instead of the
|
|
138
|
+
# adapter parsing 21.8 MB twice to answer which version this was.
|
|
139
|
+
#
|
|
140
|
+
# @api private
|
|
141
|
+
GENERATED_AT = T.let("DateGenerated", String)
|
|
142
|
+
|
|
143
|
+
# @api private
|
|
144
|
+
LIST = T.let(Parsers::XmlRecords.new(records: [DESIGNATION, GENERATED_AT]), Parsers::XmlRecords)
|
|
145
|
+
|
|
146
|
+
# Records that could not be used, and fields that could not be read.
|
|
147
|
+
# Read after #parse; sync orchestration (#34) reports them.
|
|
148
|
+
sig { returns(T::Array[Parsers::Warning]) }
|
|
149
|
+
attr_reader :warnings
|
|
150
|
+
|
|
151
|
+
sig { params(args: T.untyped, options: T.untyped).void }
|
|
152
|
+
def initialize(*args, **options)
|
|
153
|
+
super
|
|
154
|
+
@warnings = T.let([], T::Array[Parsers::Warning])
|
|
155
|
+
@unmapped = T.let([], T::Array[Parsers::Warning])
|
|
156
|
+
@generated_at = T.let(nil, T.nilable(String))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
|
|
160
|
+
def parse(raw)
|
|
161
|
+
reader = LIST.read(raw)
|
|
162
|
+
entities = build(reader)
|
|
163
|
+
@warnings = reader.warnings + @unmapped
|
|
164
|
+
entities
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# `07/09/2026` -- the date the FCDO stamps on the document and shows on
|
|
168
|
+
# its own download page, which is the string an examiner asking which
|
|
169
|
+
# version a decision was made against will recognise. Falls back to
|
|
170
|
+
# Last-Modified for a payload handed straight to #snapshot.
|
|
171
|
+
sig { override.returns(T.nilable(String)) }
|
|
172
|
+
def source_version = @generated_at || super
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
sig { params(reader: Parsers::XmlRecords::Reader).returns(T::Array[Entity]) }
|
|
177
|
+
def build(reader)
|
|
178
|
+
@unmapped = []
|
|
179
|
+
@generated_at = nil
|
|
180
|
+
reader.filter_map do |node|
|
|
181
|
+
# `filter_map` keeps a truthy block value, so the generation date has
|
|
182
|
+
# to be captured and then explicitly not returned as a record.
|
|
183
|
+
if node.name == GENERATED_AT
|
|
184
|
+
@generated_at ||= node.text
|
|
185
|
+
next nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
record = Record.new(node)
|
|
189
|
+
entity = record.entity
|
|
190
|
+
entity.nil? ? note_nameless(node) : entity
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A record with no name cannot be screened against and is never what the
|
|
195
|
+
# FCDO meant to publish. Every one of the 6,334 published today carries a
|
|
196
|
+
# name; 26 `<Name>` elements are empty of every part, which Record drops
|
|
197
|
+
# on their own. The warning exists so that the day a whole designation
|
|
198
|
+
# arrives nameless, it is visible rather than absent.
|
|
199
|
+
sig { params(node: Parsers::XmlRecords::Record).returns(NilClass) }
|
|
200
|
+
def note_nameless(node)
|
|
201
|
+
@unmapped << Parsers::Warning.new(
|
|
202
|
+
line: node.line,
|
|
203
|
+
message: "<#{node.name}> #{node["UniqueID"].inspect} has no name and was skipped"
|
|
204
|
+
)
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
require "active_sanction/sources/uk_sanctions_list/published_date"
|
|
212
|
+
require "active_sanction/sources/uk_sanctions_list/record"
|
|
213
|
+
|
|
214
|
+
ActiveSanction::Sources.register(ActiveSanction::Sources::UkSanctionsList)
|