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,282 @@
|
|
|
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/identifier"
|
|
9
|
+
require "active_sanction/partial_date"
|
|
10
|
+
require "active_sanction/parsers"
|
|
11
|
+
require "active_sanction/sources/remarks"
|
|
12
|
+
require "active_sanction/sources/canada_sema/source_ref"
|
|
13
|
+
|
|
14
|
+
module ActiveSanction
|
|
15
|
+
module Sources
|
|
16
|
+
class CanadaSema < Base
|
|
17
|
+
# One `<record>` turned into an Entity.
|
|
18
|
+
#
|
|
19
|
+
# Separate from the adapter because they are two jobs: the adapter says
|
|
20
|
+
# what the list is and where it lives, and this says what Global Affairs'
|
|
21
|
+
# elements mean. The mapping is where all the judgment sits, so it is
|
|
22
|
+
# worth being able to read it on its own.
|
|
23
|
+
#
|
|
24
|
+
# Every element is optional and the file has no nesting, so which of the
|
|
25
|
+
# three record shapes a `<record>` is has to be read off which elements
|
|
26
|
+
# it happens to carry. See #type.
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
class Record
|
|
30
|
+
extend T::Sig
|
|
31
|
+
|
|
32
|
+
COUNTRY = T.let("Country-Pays", String)
|
|
33
|
+
LAST_NAME = T.let("LastName-NomDeFamille", String)
|
|
34
|
+
GIVEN_NAME = T.let("GivenName-Prenom", String)
|
|
35
|
+
ENTITY_OR_SHIP = T.let("EntityOrShip-EntiteOuNavire", String)
|
|
36
|
+
ALIASES = T.let("Aliases-Alias", String)
|
|
37
|
+
TITLE_OR_SHIP_TYPE = T.let("TitleOrShipType-TitreOuTypeDeNavire", String)
|
|
38
|
+
IMO = T.let("ShipIMONumber-NumeroOMIDuNavire", String)
|
|
39
|
+
BORN_OR_BUILT = T.let("DateOfBirthOrShipBuildDate-DateDeNaissanceOuDateDeConstructionDuNavire", String)
|
|
40
|
+
SCHEDULE = T.let("Schedule-Annexe", String)
|
|
41
|
+
ITEM = T.let("Item-NumeroDarticle", String)
|
|
42
|
+
LISTED_ON = T.let("DateOfListing-DateDinscription", String)
|
|
43
|
+
|
|
44
|
+
# The bilingual separator, which is not one string. Countries pair on
|
|
45
|
+
# ` / ` and vessel types and titles pair on `|`, which the publisher
|
|
46
|
+
# writes with a space on neither, either or both sides and sometimes
|
|
47
|
+
# with a line break inside the half either side of it. The English half
|
|
48
|
+
# is the one before the first separator; whichever separator comes
|
|
49
|
+
# first in the string is the one that split it.
|
|
50
|
+
BILINGUAL = T.let(%r{\s*\|\s*|\s+/\s+}, Regexp)
|
|
51
|
+
|
|
52
|
+
# The one separator in the alias field that means what it looks like.
|
|
53
|
+
# See CanadaSema's class comment for why the comma is left alone.
|
|
54
|
+
ALIAS_SEPARATOR = T.let(";", String)
|
|
55
|
+
|
|
56
|
+
# Nine of the 2,598 published dates are two candidate dates rather than
|
|
57
|
+
# one -- `1972-08-26 or 1974-05-31`, `1987/1988` -- which is exactly
|
|
58
|
+
# what `Entity#dates_of_birth` is plural for. Only consulted after the
|
|
59
|
+
# whole string has failed to parse, so `1963-1964` stays the span the
|
|
60
|
+
# publisher wrote and no ISO date is ever split on its own separator.
|
|
61
|
+
ALTERNATIVES = T.let(%r{\s+or\s+|\s*/\s*}i, Regexp)
|
|
62
|
+
|
|
63
|
+
# Every element carrying a name or a name-like string arrives padded
|
|
64
|
+
# with whitespace the publisher did not mean, and 764 of those pads are
|
|
65
|
+
# U+00A0 rather than a space -- which `String#strip` does not touch, so
|
|
66
|
+
# `Premier ` would reach the matcher as a name nothing types.
|
|
67
|
+
WHITESPACE = T.let(/[[:space:]]+/, Regexp)
|
|
68
|
+
|
|
69
|
+
# For comparing two names the publisher wrote with different
|
|
70
|
+
# punctuation, which is the only thing that makes an alias a duplicate
|
|
71
|
+
# of the primary name rather than a second name.
|
|
72
|
+
INSIGNIFICANT = T.let(/[^[:alnum:]]+/, Regexp)
|
|
73
|
+
|
|
74
|
+
sig { returns(Parsers::XmlRecords::Record) }
|
|
75
|
+
attr_reader :node
|
|
76
|
+
|
|
77
|
+
# Fields of this record that could not be read. Collected by the
|
|
78
|
+
# adapter after #entity -- see CanadaSema#build.
|
|
79
|
+
sig { returns(T::Array[Parsers::Warning]) }
|
|
80
|
+
attr_reader :warnings
|
|
81
|
+
|
|
82
|
+
sig { params(node: Parsers::XmlRecords::Record).void }
|
|
83
|
+
def initialize(node)
|
|
84
|
+
@node = T.let(node, Parsers::XmlRecords::Record)
|
|
85
|
+
@warnings = T.let([], T::Array[Parsers::Warning])
|
|
86
|
+
@source_ref = T.let(nil, T.nilable(String))
|
|
87
|
+
@names = T.let(nil, T.nilable(T::Array[Name]))
|
|
88
|
+
@primary_name = T.let(nil, T.nilable(String))
|
|
89
|
+
@dates_of_birth = T.let(nil, T.nilable(T::Array[PartialDate]))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The entity, or nil for a record with no name in any of its three name
|
|
93
|
+
# elements -- which cannot be screened against and is never what Global
|
|
94
|
+
# Affairs meant to publish.
|
|
95
|
+
sig { returns(T.nilable(Entity)) }
|
|
96
|
+
def entity
|
|
97
|
+
return nil if names.empty?
|
|
98
|
+
|
|
99
|
+
Entity.new(source: :canada_sema, source_ref: source_ref, type: type, names: names,
|
|
100
|
+
identifiers: identifiers, dates_of_birth: dates_of_birth,
|
|
101
|
+
programs: programs, listed_on: listed_on, remarks: remarks)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# An IMO number is only ever published for a ship, and a ship's name is
|
|
105
|
+
# published in the same element an organization's is, so the number is
|
|
106
|
+
# what separates them. The 1,445 records naming an entity with no IMO
|
|
107
|
+
# number are called organizations: some of them may be ships the
|
|
108
|
+
# publisher gave no number for, and nothing in the file says which.
|
|
109
|
+
sig { returns(Symbol) }
|
|
110
|
+
def type
|
|
111
|
+
return :vessel unless node.null?(IMO)
|
|
112
|
+
|
|
113
|
+
node.null?(ENTITY_OR_SHIP) ? :individual : :organization
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Derived, because Canada publishes no id at all. See SourceRef.
|
|
117
|
+
sig { returns(String) }
|
|
118
|
+
def source_ref
|
|
119
|
+
@source_ref ||= SourceRef.for(country: node[COUNTRY], schedule: node[SCHEDULE],
|
|
120
|
+
item: node[ITEM], name: primary_name)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The primary name, then every alias the publisher separated
|
|
124
|
+
# unambiguously, minus any that is only the primary name repunctuated.
|
|
125
|
+
sig { returns(T::Array[Name]) }
|
|
126
|
+
def names
|
|
127
|
+
@names ||= build_names
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `Country-Pays` is not a nationality and must never be read as one:
|
|
131
|
+
# a Ukrainian official listed under the Special Economic Measures
|
|
132
|
+
# (Russia) Regulations is published under `Russia / Russie`. What the
|
|
133
|
+
# element names is the regulation the person is listed by, which is a
|
|
134
|
+
# sanctions program -- and for 80 of the records it says so outright,
|
|
135
|
+
# naming the Justice for Victims of Corrupt Foreign Officials
|
|
136
|
+
# Regulations rather than a country at all.
|
|
137
|
+
sig { returns(T::Array[String]) }
|
|
138
|
+
def programs = [english(node[COUNTRY])].compact
|
|
139
|
+
|
|
140
|
+
sig { returns(T.nilable(PartialDate)) }
|
|
141
|
+
def listed_on = PartialDate.parse(node[LISTED_ON])
|
|
142
|
+
|
|
143
|
+
# Empty for a vessel: the element it would come from is the ship's
|
|
144
|
+
# build date, and a hull laid down in 1980 has not got a date of birth.
|
|
145
|
+
sig { returns(T::Array[PartialDate]) }
|
|
146
|
+
def dates_of_birth
|
|
147
|
+
return [] if type == :vessel
|
|
148
|
+
|
|
149
|
+
@dates_of_birth ||= published_dates
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A ship's IMO number, which is permanent, unique and assigned by the
|
|
153
|
+
# IMO rather than by an owner -- so it is by far the most decisive
|
|
154
|
+
# thing this list publishes about anything. Filed as a registration
|
|
155
|
+
# number, which is what it is, with the note saying whose.
|
|
156
|
+
sig { returns(T::Array[Identifier]) }
|
|
157
|
+
def identifiers
|
|
158
|
+
return [] if node.null?(IMO)
|
|
159
|
+
|
|
160
|
+
[Identifier.new(kind: :registration_number, value: collapse(node[IMO]), note: "IMO number")]
|
|
161
|
+
rescue ArgumentError
|
|
162
|
+
[]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Canada publishes no free text of its own -- there is no comment or
|
|
166
|
+
# remarks element anywhere in the file -- so everything here is behind
|
|
167
|
+
# the marker, and `Remarks.published` on a Canadian record is correctly
|
|
168
|
+
# nil. What is kept is the citation the id was derived from, which is
|
|
169
|
+
# what an examiner needs to look a listing up in the Gazette, and both
|
|
170
|
+
# halves of every bilingual value verbatim.
|
|
171
|
+
sig { returns(T.nilable(String)) }
|
|
172
|
+
def remarks = Remarks.build(nil, extras)
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
sig { returns(T::Array[Name]) }
|
|
177
|
+
def build_names
|
|
178
|
+
primary = primary_name
|
|
179
|
+
return [] if primary.nil?
|
|
180
|
+
|
|
181
|
+
names = [Name.new(value: primary, kind: :primary)]
|
|
182
|
+
alias_values.each do |value|
|
|
183
|
+
next if names.any? { |name| comparable(name.value) == comparable(value) }
|
|
184
|
+
|
|
185
|
+
names << Name.new(value: value, kind: :aka)
|
|
186
|
+
end
|
|
187
|
+
names
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Canada files a person under surname then given names, which joined in
|
|
191
|
+
# that order reads "Balaba Dmitry Vladimirovich" -- not how anyone types
|
|
192
|
+
# a name into a screening form. The parts are joined the way they are
|
|
193
|
+
# spoken instead; the publisher's filing order is not lost, because the
|
|
194
|
+
# two elements are what the remark records the citation against.
|
|
195
|
+
sig { returns(T.nilable(String)) }
|
|
196
|
+
def primary_name
|
|
197
|
+
@primary_name ||= collapse(node[ENTITY_OR_SHIP]) ||
|
|
198
|
+
collapse([node[GIVEN_NAME], node[LAST_NAME]].compact.join(" "))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
sig { returns(T::Array[String]) }
|
|
202
|
+
def alias_values
|
|
203
|
+
collapse(node[ALIASES]).to_s.split(ALIAS_SEPARATOR).filter_map { |value| collapse(value) }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
sig { params(value: String).returns(String) }
|
|
207
|
+
def comparable(value) = value.gsub(INSIGNIFICANT, "").downcase
|
|
208
|
+
|
|
209
|
+
# A date the whole string reads as, or the two candidates it reads as
|
|
210
|
+
# when split. Anything else is kept verbatim in the remark and warned
|
|
211
|
+
# about rather than dropped: `born in the early 1970s` is real signal
|
|
212
|
+
# that this class has no shape for.
|
|
213
|
+
sig { returns(T::Array[PartialDate]) }
|
|
214
|
+
def published_dates
|
|
215
|
+
raw = collapse(node[BORN_OR_BUILT])
|
|
216
|
+
return [] if raw.nil?
|
|
217
|
+
|
|
218
|
+
whole = PartialDate.parse(raw)
|
|
219
|
+
return [whole] unless whole.nil?
|
|
220
|
+
|
|
221
|
+
alternatives(raw)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
sig { params(raw: String).returns(T::Array[PartialDate]) }
|
|
225
|
+
def alternatives(raw)
|
|
226
|
+
dates = raw.split(ALTERNATIVES).filter_map { |part| PartialDate.parse(part) }
|
|
227
|
+
note_unreadable(raw) if dates.empty?
|
|
228
|
+
dates
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig { params(raw: String).void }
|
|
232
|
+
def note_unreadable(raw)
|
|
233
|
+
@warnings << Parsers::Warning.new(
|
|
234
|
+
line: node.line,
|
|
235
|
+
message: "#{primary_name.inspect} has a date this parser does not read (#{raw.inspect}); " \
|
|
236
|
+
"it was kept in remarks instead"
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Label/value pairs for Remarks.build, which drops the ones the record
|
|
241
|
+
# left blank. The bilingual values go in whole: the English half is
|
|
242
|
+
# what the canonical fields carry, and the remark is where the French
|
|
243
|
+
# the publisher wrote survives. Only the publisher's own line wrapping
|
|
244
|
+
# is taken out -- sixteen vessel types are published with a newline
|
|
245
|
+
# mid-phrase, and a remark is a line in a report.
|
|
246
|
+
sig { returns(T::Array[T.untyped]) }
|
|
247
|
+
def extras
|
|
248
|
+
[["Country", node[COUNTRY]], ["Schedule", node[SCHEDULE]], ["Item", node[ITEM]],
|
|
249
|
+
[title_label, node[TITLE_OR_SHIP_TYPE]], *date_extras]
|
|
250
|
+
.map { |label, value| [label, collapse(value)] }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# One element, two meanings, split by what the record is -- the same
|
|
254
|
+
# split the date element needs, for the same reason.
|
|
255
|
+
sig { returns(String) }
|
|
256
|
+
def title_label = type == :vessel ? "Vessel type" : "Title"
|
|
257
|
+
|
|
258
|
+
# A build year for a ship, and for anyone else the date string only if
|
|
259
|
+
# nothing could be read from it, so a date already in `dates_of_birth`
|
|
260
|
+
# is not also printed here.
|
|
261
|
+
sig { returns(T::Array[T.untyped]) }
|
|
262
|
+
def date_extras
|
|
263
|
+
return [["Built", node[BORN_OR_BUILT]]] if type == :vessel
|
|
264
|
+
return [] if dates_of_birth.any?
|
|
265
|
+
|
|
266
|
+
[["Date of birth", node[BORN_OR_BUILT]]]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The English half of a value the publisher wrote in both languages,
|
|
270
|
+
# and the whole of one it wrote in only one.
|
|
271
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
272
|
+
def english(value) = collapse(value.to_s.split(BILINGUAL, 2).first)
|
|
273
|
+
|
|
274
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
275
|
+
def collapse(value)
|
|
276
|
+
string = value.to_s.split(WHITESPACE).join(" ")
|
|
277
|
+
string.empty? ? nil : -string
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Sources
|
|
10
|
+
class CanadaSema < Base
|
|
11
|
+
# The identity Canada does not publish, derived so that the same record
|
|
12
|
+
# in two syncs is the same record.
|
|
13
|
+
#
|
|
14
|
+
# SourceRef.for(country: "Russia / Russie", schedule: "1, Part 1",
|
|
15
|
+
# item: "1114", name: "Kirill Alekseevich MORDASHOV")
|
|
16
|
+
#
|
|
17
|
+
# ### Why anything has to be derived at all
|
|
18
|
+
#
|
|
19
|
+
# Global Affairs publishes no id. What it publishes is a citation --
|
|
20
|
+
# which regulation, which schedule, which item -- and #35 diffs two syncs
|
|
21
|
+
# by comparing records under their ids, so without one every sync reports
|
|
22
|
+
# the entire list as removed and re-added, and a diff that says
|
|
23
|
+
# everything changed says nothing at all.
|
|
24
|
+
#
|
|
25
|
+
# ### What goes into it, and the one part that is not obvious
|
|
26
|
+
#
|
|
27
|
+
# The citation alone -- country, schedule, item -- is already unique
|
|
28
|
+
# across all 5,690 published records, so on the face of it the name is
|
|
29
|
+
# redundant. It is in the hash anyway, because of what happens when a
|
|
30
|
+
# schedule is amended.
|
|
31
|
+
#
|
|
32
|
+
# Item numbers are positions in a list, not identifiers: delete item 5
|
|
33
|
+
# from a schedule and everything after it moves up one. Hashing the
|
|
34
|
+
# citation alone would then hand item 6's old id to the person who used
|
|
35
|
+
# to be item 7, and the diff would report that one person quietly changed
|
|
36
|
+
# their name -- which is the same shape as a correction and reads as one.
|
|
37
|
+
# With the name in the hash, that amendment reports as a removal and an
|
|
38
|
+
# addition, which is noisier and true.
|
|
39
|
+
#
|
|
40
|
+
# The cost runs the other way: correcting a typo in a published name
|
|
41
|
+
# re-ids that record, so a spelling fix reads as one person leaving and
|
|
42
|
+
# another arriving. Churn in a diff is a nuisance; one id covering two
|
|
43
|
+
# different people is a screening failure, so the trade goes this way.
|
|
44
|
+
#
|
|
45
|
+
# ### Stability
|
|
46
|
+
#
|
|
47
|
+
# The digest is taken over the normalized parts joined by a separator
|
|
48
|
+
# that cannot occur in any of them, so an id depends on nothing but the
|
|
49
|
+
# record's own bytes -- not on iteration order, not on position in the
|
|
50
|
+
# file, not on the run. Changing NORMALIZE, SEPARATOR or LENGTH re-ids
|
|
51
|
+
# every Canadian record ever stored, which makes each of them a versioned
|
|
52
|
+
# decision rather than a cleanup.
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
module SourceRef
|
|
56
|
+
extend T::Sig
|
|
57
|
+
|
|
58
|
+
# Case and the publisher's stray padding are noise: `Venezuela ` and
|
|
59
|
+
# `1, Part 1 ` are published both with and without their trailing
|
|
60
|
+
# space, and a record must not change id when a space does. Nothing
|
|
61
|
+
# further is folded -- not punctuation, not diacritics -- because every
|
|
62
|
+
# additional fold is another way for two genuinely different records to
|
|
63
|
+
# collide into one id.
|
|
64
|
+
NORMALIZE = T.let(/[[:space:]]+/, Regexp)
|
|
65
|
+
|
|
66
|
+
# A NUL cannot appear in XML character data at all, so no two different
|
|
67
|
+
# sets of field values can be re-parenthesized into each other:
|
|
68
|
+
# ("a", "bc") and ("ab", "c") hash apart.
|
|
69
|
+
SEPARATOR = T.let("\u0000", String)
|
|
70
|
+
|
|
71
|
+
# 64 bits of SHA-256. Across 5,690 records the chance of any collision
|
|
72
|
+
# at all is about one in a trillion, and an id this length stays
|
|
73
|
+
# readable in a log line and in the report that quotes it.
|
|
74
|
+
LENGTH = T.let(16, Integer)
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
sig { params(country: T.untyped, schedule: T.untyped, item: T.untyped, name: T.untyped).returns(String) }
|
|
79
|
+
def for(country:, schedule:, item:, name:)
|
|
80
|
+
parts = [country, schedule, item, name].map { |part| normalize(part) }
|
|
81
|
+
-T.must(Digest::SHA256.hexdigest(parts.join(SEPARATOR))[0, LENGTH])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { params(value: T.untyped).returns(String) }
|
|
85
|
+
def normalize(value) = value.to_s.split(NORMALIZE).join(" ").downcase
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
# Canada's consolidated sanctions list: everyone named in a schedule to a
|
|
13
|
+
# regulation made under the Special Economic Measures Act, plus the people
|
|
14
|
+
# listed under the Justice for Victims of Corrupt Foreign Officials Act.
|
|
15
|
+
#
|
|
16
|
+
# snapshot = ActiveSanction::Sources[:canada_sema].new.sync
|
|
17
|
+
#
|
|
18
|
+
# ### One file, one flat record shape
|
|
19
|
+
#
|
|
20
|
+
# https://www.international.gc.ca/.../sanctions/sema-lmes.xml 2.9 MB
|
|
21
|
+
#
|
|
22
|
+
# <data-set><record> 5,690 records
|
|
23
|
+
#
|
|
24
|
+
# Every record is the same eleven optional elements with no nesting, which
|
|
25
|
+
# makes this the simplest list the gem reads and the hardest one to give a
|
|
26
|
+
# stable identity to.
|
|
27
|
+
#
|
|
28
|
+
# ### The three things that make this source awkward
|
|
29
|
+
#
|
|
30
|
+
# **No stable identifier.** Global Affairs publishes no id of any kind.
|
|
31
|
+
# What it publishes is where in the law a person appears -- the schedule
|
|
32
|
+
# and the item number within it -- which is scoped per regulation and is
|
|
33
|
+
# renumbered whenever a schedule is amended. Record derives a deterministic
|
|
34
|
+
# synthetic id from it; see Record::SOURCE_REF_PARTS for what goes in and
|
|
35
|
+
# why the name is part of it.
|
|
36
|
+
#
|
|
37
|
+
# **Bilingual tags and values.** Element names pair English and French
|
|
38
|
+
# (`Country-Pays`), and so do the values in three of them. The separator is
|
|
39
|
+
# not one string: countries use ` / ` (`Belarus / Bélarus`) and vessel
|
|
40
|
+
# types and titles use `|` (`Oil Tanker | Navire-citerne`), sometimes with
|
|
41
|
+
# no space and sometimes wrapped across a line. Only those three elements
|
|
42
|
+
# are split; a name is never split, because `Islamic Revolutionary Guard
|
|
43
|
+
# Corps/Corps des Gardiens de la Révolution islamique` and `Victory/Pobeda
|
|
44
|
+
# Political Bloc` are the same punctuation meaning two different things and
|
|
45
|
+
# nothing in the file separates them.
|
|
46
|
+
#
|
|
47
|
+
# **The date element doubles as a ship's build date.** It is named
|
|
48
|
+
# `DateOfBirthOrShipBuildDate-...` and means whichever the record is. A
|
|
49
|
+
# build year is not a date of birth, so a vessel's goes to remarks and its
|
|
50
|
+
# `dates_of_birth` stays empty.
|
|
51
|
+
#
|
|
52
|
+
# ### What a clean Canadian result is worth
|
|
53
|
+
#
|
|
54
|
+
# Less than a clean OFAC one, and a screening policy should know it. Canada
|
|
55
|
+
# publishes no nationality, no address, no place of birth and no document
|
|
56
|
+
# number for any of the 5,690 records: an individual is a surname, given
|
|
57
|
+
# names, a date of birth roughly half the time, and a free-text alias
|
|
58
|
+
# field. There is nothing here to make a name match decisive with, which is
|
|
59
|
+
# the opposite of OFAC, where a passport number usually settles it.
|
|
60
|
+
#
|
|
61
|
+
# ### Aliases, and the comma this adapter refuses to split on
|
|
62
|
+
#
|
|
63
|
+
# 3,195 records carry an `Aliases-Alias` element, which is free text with
|
|
64
|
+
# no declared separator. Semicolons are unambiguous and are split on. Commas
|
|
65
|
+
# are not, and splitting on them manufactures names that match far too much:
|
|
66
|
+
# `Завод "Дагдизель", АО` and `М Инвест, ООО` would each yield a bare
|
|
67
|
+
# Russian legal form as an alias, and `Министерство образования, науки и
|
|
68
|
+
# молодежи Республики Крым` is one ministry, not two. So a comma-joined
|
|
69
|
+
# alias field stays one alias. That costs recall on roughly 300 records
|
|
70
|
+
# whose primary name is published anyway, and it is the cheaper of the two
|
|
71
|
+
# mistakes.
|
|
72
|
+
class CanadaSema < Base
|
|
73
|
+
extend T::Sig
|
|
74
|
+
|
|
75
|
+
key :canada_sema
|
|
76
|
+
jurisdiction :ca
|
|
77
|
+
authority "Global Affairs Canada"
|
|
78
|
+
format :xml
|
|
79
|
+
|
|
80
|
+
# The one that most often surprises people, and the reason this field
|
|
81
|
+
# exists at all. Global Affairs permits reproduction for
|
|
82
|
+
# non-commercial purposes without charge or further permission, and
|
|
83
|
+
# requires prior written permission for commercial redistribution --
|
|
84
|
+
# so a hosted service reselling this list is in a different position
|
|
85
|
+
# from an application screening against it in its own process.
|
|
86
|
+
licence_notice "Reproducible for non-commercial purposes without " \
|
|
87
|
+
"charge or further permission, with the source " \
|
|
88
|
+
"identified. Commercial redistribution requires prior " \
|
|
89
|
+
"written permission from Global Affairs Canada. " \
|
|
90
|
+
"Verified 2026-09-11."
|
|
91
|
+
licence_url "https://www.international.gc.ca/gac-amc/notices-avis.aspx?lang=eng"
|
|
92
|
+
|
|
93
|
+
url :main,
|
|
94
|
+
"https://www.international.gc.ca/world-monde/assets/office_docs/" \
|
|
95
|
+
"international_relations-relations_internationales/sanctions/sema-lmes.xml"
|
|
96
|
+
|
|
97
|
+
# @api private
|
|
98
|
+
RECORD = T.let("record", String)
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
LIST = T.let(Parsers::XmlRecords.new(records: RECORD), Parsers::XmlRecords)
|
|
102
|
+
|
|
103
|
+
# Records that could not be used, and fields that could not be read.
|
|
104
|
+
# Read after #parse; sync orchestration (#34) reports them.
|
|
105
|
+
sig { returns(T::Array[Parsers::Warning]) }
|
|
106
|
+
attr_reader :warnings
|
|
107
|
+
|
|
108
|
+
sig { params(args: T.untyped, options: T.untyped).void }
|
|
109
|
+
def initialize(*args, **options)
|
|
110
|
+
super
|
|
111
|
+
@warnings = T.let([], T::Array[Parsers::Warning])
|
|
112
|
+
@unmapped = T.let([], T::Array[Parsers::Warning])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { override.params(raw: T.untyped).returns(T::Array[Entity]) }
|
|
116
|
+
def parse(raw)
|
|
117
|
+
reader = LIST.read(raw)
|
|
118
|
+
entities = build(reader)
|
|
119
|
+
@warnings = reader.warnings + @unmapped
|
|
120
|
+
entities
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
sig { params(reader: Parsers::XmlRecords::Reader).returns(T::Array[Entity]) }
|
|
126
|
+
def build(reader)
|
|
127
|
+
@unmapped = []
|
|
128
|
+
reader.filter_map do |node|
|
|
129
|
+
record = Record.new(node)
|
|
130
|
+
entity = record.entity
|
|
131
|
+
@unmapped.concat(record.warnings)
|
|
132
|
+
entity.nil? ? note_nameless(node) : entity
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A record with no name in any of its three name elements cannot be
|
|
137
|
+
# screened against. None of the 5,690 published today is nameless; the
|
|
138
|
+
# warning exists so that the day one is, it is visible rather than absent.
|
|
139
|
+
sig { params(node: Parsers::XmlRecords::Record).returns(NilClass) }
|
|
140
|
+
def note_nameless(node)
|
|
141
|
+
@unmapped << Parsers::Warning.new(
|
|
142
|
+
line: node.line,
|
|
143
|
+
message: "<#{node.name}> at item #{node[Record::ITEM].inspect} has no name and was skipped"
|
|
144
|
+
)
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
require "active_sanction/sources/canada_sema/record"
|
|
152
|
+
|
|
153
|
+
ActiveSanction::Sources.register(ActiveSanction::Sources::CanadaSema)
|