active_sanction 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +538 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/CONTRIBUTING.md +312 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +616 -0
  7. data/SECURITY.md +97 -0
  8. data/active_sanction.gemspec +98 -0
  9. data/docs/adding_a_source.md +1083 -0
  10. data/docs/api_stability.md +357 -0
  11. data/docs/bundle_format.md +407 -0
  12. data/lib/active_sanction/address.rb +170 -0
  13. data/lib/active_sanction/client.rb +341 -0
  14. data/lib/active_sanction/configuration.rb +721 -0
  15. data/lib/active_sanction/countries.txt +292 -0
  16. data/lib/active_sanction/country.rb +164 -0
  17. data/lib/active_sanction/deprecation.rb +168 -0
  18. data/lib/active_sanction/diff/change.rb +208 -0
  19. data/lib/active_sanction/diff.rb +348 -0
  20. data/lib/active_sanction/doctor/checkup.rb +356 -0
  21. data/lib/active_sanction/doctor/diagnosis.rb +245 -0
  22. data/lib/active_sanction/doctor/finding.rb +183 -0
  23. data/lib/active_sanction/doctor/profile.rb +384 -0
  24. data/lib/active_sanction/doctor/report.rb +227 -0
  25. data/lib/active_sanction/doctor.rb +347 -0
  26. data/lib/active_sanction/entity.rb +293 -0
  27. data/lib/active_sanction/error.rb +332 -0
  28. data/lib/active_sanction/fetcher/result.rb +116 -0
  29. data/lib/active_sanction/fetcher.rb +242 -0
  30. data/lib/active_sanction/http_client/errors.rb +94 -0
  31. data/lib/active_sanction/http_client/response.rb +129 -0
  32. data/lib/active_sanction/http_client.rb +324 -0
  33. data/lib/active_sanction/identifier.rb +212 -0
  34. data/lib/active_sanction/index/builder.rb +89 -0
  35. data/lib/active_sanction/index/candidate.rb +63 -0
  36. data/lib/active_sanction/index/entry.rb +66 -0
  37. data/lib/active_sanction/index/features.rb +112 -0
  38. data/lib/active_sanction/index.rb +381 -0
  39. data/lib/active_sanction/match_result.rb +345 -0
  40. data/lib/active_sanction/matcher.rb +388 -0
  41. data/lib/active_sanction/name.rb +183 -0
  42. data/lib/active_sanction/normalizer/cache.rb +82 -0
  43. data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
  44. data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
  45. data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
  46. data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
  47. data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
  48. data/lib/active_sanction/normalizer/dictionary.rb +243 -0
  49. data/lib/active_sanction/normalizer/form.rb +279 -0
  50. data/lib/active_sanction/normalizer.rb +137 -0
  51. data/lib/active_sanction/parsers/column_shape.rb +274 -0
  52. data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
  53. data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
  54. data/lib/active_sanction/parsers/delimited_table.rb +151 -0
  55. data/lib/active_sanction/parsers/format.rb +107 -0
  56. data/lib/active_sanction/parsers/join.rb +149 -0
  57. data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
  58. data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
  59. data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
  60. data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
  61. data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
  62. data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
  63. data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
  64. data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
  65. data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
  66. data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
  67. data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
  68. data/lib/active_sanction/parsers/xml_records.rb +140 -0
  69. data/lib/active_sanction/parsers.rb +94 -0
  70. data/lib/active_sanction/partial_date/parser.rb +124 -0
  71. data/lib/active_sanction/partial_date.rb +303 -0
  72. data/lib/active_sanction/payload_cache/checksum.rb +66 -0
  73. data/lib/active_sanction/payload_cache/entry.rb +304 -0
  74. data/lib/active_sanction/payload_cache.rb +366 -0
  75. data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
  76. data/lib/active_sanction/phonetics.rb +40 -0
  77. data/lib/active_sanction/query.rb +290 -0
  78. data/lib/active_sanction/rescreen/alert.rb +314 -0
  79. data/lib/active_sanction/rescreen.rb +332 -0
  80. data/lib/active_sanction/scorer/adjustments.rb +241 -0
  81. data/lib/active_sanction/scorer/name_score.rb +279 -0
  82. data/lib/active_sanction/scorer/reason.rb +136 -0
  83. data/lib/active_sanction/scorer/result.rb +129 -0
  84. data/lib/active_sanction/scorer/subject.rb +235 -0
  85. data/lib/active_sanction/scorer/weights.rb +306 -0
  86. data/lib/active_sanction/scorer.rb +303 -0
  87. data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
  88. data/lib/active_sanction/similarity/levenshtein.rb +179 -0
  89. data/lib/active_sanction/similarity/token_set.rb +159 -0
  90. data/lib/active_sanction/similarity/token_sort.rb +120 -0
  91. data/lib/active_sanction/similarity.rb +183 -0
  92. data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
  93. data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
  94. data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
  95. data/lib/active_sanction/snapshot/bundle.rb +381 -0
  96. data/lib/active_sanction/snapshot.rb +305 -0
  97. data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
  98. data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
  99. data/lib/active_sanction/sources/australia_dfat.rb +291 -0
  100. data/lib/active_sanction/sources/base.rb +295 -0
  101. data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
  102. data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
  103. data/lib/active_sanction/sources/canada_sema.rb +153 -0
  104. data/lib/active_sanction/sources/definition.rb +360 -0
  105. data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
  106. data/lib/active_sanction/sources/eu_fsf.rb +203 -0
  107. data/lib/active_sanction/sources/ofac/record.rb +251 -0
  108. data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
  109. data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
  110. data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
  111. data/lib/active_sanction/sources/ofac.rb +248 -0
  112. data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
  113. data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
  114. data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
  115. data/lib/active_sanction/sources/remarks.rb +84 -0
  116. data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
  117. data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
  118. data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
  119. data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
  120. data/lib/active_sanction/sources/un_consolidated.rb +140 -0
  121. data/lib/active_sanction/sources.rb +196 -0
  122. data/lib/active_sanction/storage/active_record/reader.rb +124 -0
  123. data/lib/active_sanction/storage/active_record/row.rb +175 -0
  124. data/lib/active_sanction/storage/active_record/writer.rb +141 -0
  125. data/lib/active_sanction/storage/active_record.rb +277 -0
  126. data/lib/active_sanction/storage/base.rb +216 -0
  127. data/lib/active_sanction/storage/file_system.rb +373 -0
  128. data/lib/active_sanction/storage/memory.rb +67 -0
  129. data/lib/active_sanction/storage/meta.rb +188 -0
  130. data/lib/active_sanction/storage.rb +74 -0
  131. data/lib/active_sanction/subject.rb +271 -0
  132. data/lib/active_sanction/sync/report.rb +245 -0
  133. data/lib/active_sanction/sync/result.rb +296 -0
  134. data/lib/active_sanction/sync.rb +390 -0
  135. data/lib/active_sanction/validator_store/file_system.rb +103 -0
  136. data/lib/active_sanction/validator_store/memory.rb +41 -0
  137. data/lib/active_sanction/validator_store.rb +119 -0
  138. data/lib/active_sanction/validators.rb +195 -0
  139. data/lib/active_sanction/version.rb +23 -0
  140. data/lib/active_sanction.rb +331 -0
  141. data/lib/generators/active_sanction/install/install_generator.rb +60 -0
  142. data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
  143. metadata +236 -0
@@ -0,0 +1,203 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/sources"
7
+ require "active_sanction/sources/ofac"
8
+
9
+ module ActiveSanction
10
+ module Sources
11
+ # OFAC's Consolidated (non-SDN) list: everything the US sanctions that is
12
+ # not a Specially Designated National.
13
+ #
14
+ # snapshot = ActiveSanction::Sources[:ofac_consolidated].new.sync
15
+ #
16
+ # It is small -- 481 entities against the SDN list's 19,321 -- and it is
17
+ # screened anyway, because being on it still carries real legal weight. A
18
+ # Chinese semiconductor firm on the CMIC list cannot be invested in; a
19
+ # Russian bank on the SSI list can be transacted with but not lent to
20
+ # beyond a tenor the directive sets. Neither is a blocking sanction, and
21
+ # both are prohibitions a compliance team has to act on.
22
+ #
23
+ # ### One file, several lists
24
+ #
25
+ # OFAC ships the non-SDN lists as one set of three CSVs in the same shape
26
+ # as the SDN files -- the reading is Ofac's, and this adapter adds no
27
+ # parsing of its own -- but the rows in it belong to six different lists:
28
+ #
29
+ # Sectoral Sanctions Identifications (SSI) the Russia/Ukraine directives
30
+ # Non-SDN CMIC Chinese military-industrial firms
31
+ # Non-SDN Palestinian Legislative Council NS-PLC
32
+ # Non-SDN Menu-Based Sanctions (NS-MBS) HKAA, CAATSA, EO 14024 directives
33
+ # CAPTA foreign financial institutions
34
+ # FSE foreign sanctions evaders
35
+ #
36
+ # A hit on CMIC and a hit on NS-PLC are different findings with different
37
+ # consequences, so which one a record is on has to survive the parse.
38
+ # #lists answers it for any entity this adapter produced:
39
+ #
40
+ # ActiveSanction::Sources::OfacConsolidated.lists(entity) # => [:cmic]
41
+ # ActiveSanction::Sources::OfacConsolidated.names(entity) # => ["Non-SDN CMIC List"]
42
+ #
43
+ # It is a function of `entity.programs`, which is a canonical Entity member
44
+ # -- so it keeps working on a record that has been stored, serialized and
45
+ # read back, with no per-source column anywhere downstream. The names are
46
+ # also appended to `remarks` behind the `[source fields]` marker, which is
47
+ # where a compliance report reads them from.
48
+ #
49
+ # ### Why the programs and not a column
50
+ #
51
+ # There is no list column. CONS_PRIM.CSV has the same twelve columns as
52
+ # SDN.CSV, and the only thing in it that names a list is the program code
53
+ # -- `CMIC-EO13959`, `NS-PLC`, `HKAA`. OFAC states the membership
54
+ # explicitly in CONS_ADVANCED.XML, a 4.5 MB re-publication of the same 481
55
+ # records, and downloading the list twice to read one attribute off the
56
+ # second copy is a poor trade. So the program code is what LISTS maps.
57
+ #
58
+ # Checked against that XML's own attribution, the mapping is exact for 478
59
+ # of the 481 published records. The three it is not are all the same
60
+ # ambiguity, and SHARED is where it lives.
61
+ class OfacConsolidated < Ofac
62
+ extend T::Sig
63
+
64
+ key :ofac_consolidated
65
+
66
+ url :prim, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_PRIM.CSV"
67
+ url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_ALT.CSV"
68
+ url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_ADD.CSV"
69
+
70
+ # Ofac declares 0.90, calibrated on the SDN file, where RemarksParser
71
+ # reads about 97% of the segments. This list reads at 80.3%, and it is
72
+ # not a parse that has gone wrong: the CMIC rows publish a vocabulary the
73
+ # SDN file has no equivalent of -- `Effective Date (CMIC)`,
74
+ # `Listing Date (CMIC)`, `Purchase/Sales For Divestment`, `HKAA
75
+ # Section 5`, an equity ticker -- none of which is a name, a date of
76
+ # birth, a nationality or a document number, so none of it is anything
77
+ # this parser has a home for.
78
+ #
79
+ # Inheriting the SDN's floor made every first `ActiveSanction.doctor` run
80
+ # against a deployment with no stored snapshot warn about this list
81
+ # forever, with no fix available to whoever read the warning. 0.75 sits
82
+ # the same distance below what the file does as 0.90 does for the SDN,
83
+ # and it is a floor rather than a target: it exists for the run with
84
+ # nothing to compare against, and is not consulted once there is a
85
+ # snapshot or a committed baseline. Found by the canary (#69) on its
86
+ # first run against the published lists.
87
+ floor :remarks_coverage, 0.75
88
+
89
+ # The sub-lists, spelled the way OFAC's own `/sanctions-lists` endpoint
90
+ # spells them -- which is what a report has to print beside a hit.
91
+ #
92
+ # @api private
93
+ NAMES = T.let({
94
+ ssi: "Sectoral Sanctions Identifications List",
95
+ cmic: "Non-SDN CMIC List",
96
+ ns_plc: "Non-SDN Palestinian Legislative Council List",
97
+ ns_mbs: "Non-SDN Menu-Based Sanctions List",
98
+ capta: "CAPTA List",
99
+ fse: "FSE List"
100
+ }.freeze, T::Hash[Symbol, String])
101
+
102
+ # Program code to sub-list, for every program that names exactly one.
103
+ #
104
+ # A program absent from here is not an error: `SDGT` appears on three
105
+ # consolidated rows because those people are on the SDN list as well, and
106
+ # it says nothing about which non-SDN list they are on. FSE-IR and FSE-SY
107
+ # are carried because the FSE list is one OFAC still publishes and can
108
+ # refill, though nothing is on it today.
109
+ #
110
+ # @api private
111
+ LISTS = T.let({
112
+ "UKRAINE-EO13662" => :ssi,
113
+ "UKRAINE-EO13685" => :ssi,
114
+ "VENEZUELA-EO13850" => :ssi,
115
+ "IRAN-CON-ARMS-EO" => :ssi,
116
+ "CMIC-EO13959" => :cmic,
117
+ "NS-PLC" => :ns_plc,
118
+ "HKAA" => :ns_mbs,
119
+ "CAATSA - RUSSIA" => :ns_mbs,
120
+ "BURMA-EO14014" => :ns_mbs,
121
+ "ILLICIT-DRUGS-EO14059" => :ns_mbs,
122
+ "561-Related" => :capta,
123
+ "CAPTA" => :capta,
124
+ "FSE-IR" => :fse,
125
+ "FSE-SY" => :fse
126
+ }.freeze, T::Hash[String, Symbol])
127
+
128
+ # The one program OFAC uses for two lists, and the rule that reads it.
129
+ #
130
+ # EO 14024 is the Russia authority behind both the SSI directives and
131
+ # several menu-based determinations, so `RUSSIA-EO14024` alone means
132
+ # NS-MBS -- the Central Bank of Russia, the Ministry of Finance -- while
133
+ # `RUSSIA-EO14024` beside a program that is unambiguously SSI means the
134
+ # entity is on SSI under both authorities. That reads 92 of the 95 rows
135
+ # carrying it the way OFAC's own XML does.
136
+ #
137
+ # The three it does not are Gazprom, Transneft and Rosselkhozbank, which
138
+ # are on SSI *and* NS-MBS and come out marked SSI only. It is the safer
139
+ # direction of the two errors -- the record is still returned, still
140
+ # matched, still flagged as a non-SDN sanctions hit, and the program code
141
+ # OFAC published is on the entity verbatim for anyone who needs to look
142
+ # closer -- but it is an error, and nothing in the CSVs distinguishes
143
+ # those three from the 89 rows carrying the identical program pair.
144
+ #
145
+ # @api private
146
+ SHARED = T.let(
147
+ { "RUSSIA-EO14024" => { with: :ssi, alone: :ns_mbs } }.freeze,
148
+ T::Hash[String, T::Hash[Symbol, Symbol]]
149
+ )
150
+
151
+ # Which sub-lists a record is on, as an Array of the keys NAMES uses.
152
+ # Takes an Entity, or the programs themselves.
153
+ #
154
+ # OfacConsolidated.lists(entity) # => [:ssi]
155
+ # OfacConsolidated.lists(%w[UKRAINE-EO13662 RUSSIA-EO14024]) # => [:ssi]
156
+ # OfacConsolidated.lists(%w[RUSSIA-EO14024]) # => [:ns_mbs]
157
+ #
158
+ # Empty for a record whose programs name no list this adapter knows,
159
+ # which is what #parse warns about.
160
+ sig { params(programs: T.untyped).returns(T::Array[Symbol]) }
161
+ def self.lists(programs)
162
+ codes = programs.respond_to?(:programs) ? programs.programs : Array(programs)
163
+ certain = codes.filter_map { |code| LISTS[code] }.uniq
164
+ shared = codes.filter_map { |code| SHARED[code] }
165
+ .map { |rule| certain.include?(rule[:with]) ? rule[:with] : rule[:alone] }
166
+ (certain + shared).uniq
167
+ end
168
+
169
+ # The same answer as OFAC spells it, which is what goes in a report.
170
+ sig { params(programs: T.untyped).returns(T::Array[String]) }
171
+ def self.names(programs) = lists(programs).map { |list| NAMES.fetch(list) }
172
+
173
+ private
174
+
175
+ sig { override.returns(T.untyped) }
176
+ def record_class = Record
177
+
178
+ # A row whose programs name no sub-list is the signal that OFAC has
179
+ # added an authority: the entity is still returned, with its programs
180
+ # intact, but nothing downstream can say which list it puts it on until
181
+ # LISTS learns the code. Every other adapter's drift shows up as a
182
+ # parse warning, and so does this.
183
+ sig { override.params(record: T.untyped).void }
184
+ def note(record)
185
+ super
186
+ note_unattributed(record) if record.lists.empty?
187
+ end
188
+
189
+ sig { params(record: T.untyped).void }
190
+ def note_unattributed(record)
191
+ @unmapped << Parsers::Warning.new(
192
+ line: record.row.line,
193
+ message: "row #{record.row[:ent_num].inspect} carries no program naming a consolidated sub-list " \
194
+ "(#{record.programs.join(", ")}); it is on none this adapter knows"
195
+ )
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ require "active_sanction/sources/ofac_consolidated/record"
202
+
203
+ ActiveSanction::Sources.register(ActiveSanction::Sources::OfacConsolidated)
@@ -0,0 +1,43 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/sources"
7
+ require "active_sanction/sources/ofac"
8
+
9
+ module ActiveSanction
10
+ module Sources
11
+ # The US Specially Designated Nationals list: the largest and most
12
+ # frequently screened sanctions list there is, and the one whose format
13
+ # dictates most of what the parsing toolkits have to handle.
14
+ #
15
+ # snapshot = ActiveSanction::Sources[:ofac_sdn].new.sync
16
+ #
17
+ # ### Three files, one list
18
+ #
19
+ # OFAC publishes the SDN list as three headerless CSVs joined on `ent_num`:
20
+ #
21
+ # SDN.CSV 19,321 rows primary names, type, programs, remarks
22
+ # ALT.CSV 20,147 rows aliases -- more of them than there are entities
23
+ # ADD.CSV 25,078 rows addresses
24
+ #
25
+ # Each is fetched and cached independently by Base, because they change
26
+ # independently; the join happens in Ofac#parse, which the consolidated
27
+ # list uses too -- the two files are the same twelve columns and are read
28
+ # by the same code.
29
+ #
30
+ # What is left here is the declaration: which list this is, and where its
31
+ # three files live. Everything else, including the free-text remarks
32
+ # parsing that gives the list its secondary identifiers, is in Ofac.
33
+ class OfacSdn < Ofac
34
+ key :ofac_sdn
35
+
36
+ url :sdn, "https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV"
37
+ url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/ALT.CSV"
38
+ url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/ADD.CSV"
39
+ end
40
+ end
41
+ end
42
+
43
+ ActiveSanction::Sources.register(ActiveSanction::Sources::OfacSdn)
@@ -0,0 +1,84 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module ActiveSanction
7
+ module Sources
8
+ # The publisher's own free text, plus the fields the canonical model has no
9
+ # home for, kept together in one string.
10
+ #
11
+ # Remarks.build(row[:remarks], [["Vessel flag", "Panama"], ["Tonnage", "8000"]])
12
+ # # => "Registered in Panama [source fields] Vessel flag: Panama; Tonnage: 8000"
13
+ #
14
+ # Remarks.published(entity.remarks) # => "Registered in Panama"
15
+ #
16
+ # ### Why an adapter appends rather than drops
17
+ #
18
+ # Every list publishes something the canonical Entity has nowhere to put --
19
+ # OFAC a vessel's flag and owner, the UN a place of birth and a gender.
20
+ # Dropping it keeps the schema tidy at the cost of real screening signal,
21
+ # which is the wrong trade; putting it in remarks keeps it, at the cost of
22
+ # mixing it with the publisher's own prose.
23
+ #
24
+ # The marker is what pays that cost back. Anything reading the remark for
25
+ # what the publisher actually wrote -- OFAC's remarks parser (#19) is the
26
+ # first, and it must never see a vessel flag and read it as a nationality
27
+ # -- takes the text before the marker, which is what .published does.
28
+ #
29
+ # ### One marker, not one per source
30
+ #
31
+ # Both launch adapters invented this independently and gave it different
32
+ # spellings, which would have meant every consumer of a remark learning
33
+ # which source it came from before it could strip anything. There is one
34
+ # marker now, and one format, and adding a third source does not add a
35
+ # third convention.
36
+ module Remarks
37
+ extend T::Sig
38
+
39
+ # @api private
40
+ MARKER = T.let("[source fields]", String)
41
+
42
+ # @api private
43
+ SEPARATOR = T.let("; ", String)
44
+
45
+ # `fields` is a list of label/value pairs. A value may be an Array -- the
46
+ # UN files three designations under one element -- and a label whose
47
+ # value is missing or blank is left out entirely rather than printed
48
+ # against an empty string.
49
+ sig { params(published: T.untyped, fields: T.untyped).returns(T.nilable(String)) }
50
+ def self.build(published, fields = [])
51
+ appended = Array(fields).filter_map { |label, value| entry(label, value) }
52
+ text = string_or_nil(published)
53
+ return text if appended.empty?
54
+
55
+ [text, MARKER, appended.join(SEPARATOR)].compact.join(" ")
56
+ end
57
+
58
+ # The publisher's own text, with anything an adapter appended stripped
59
+ # back off. nil when the publisher wrote nothing and every word in the
60
+ # remark was put there by us, which is the honest answer: a consumer
61
+ # asking what the publisher said should not be handed a vessel's tonnage.
62
+ sig { params(remarks: T.untyped).returns(T.nilable(String)) }
63
+ def self.published(remarks)
64
+ string_or_nil(remarks.to_s.split(MARKER, 2).first)
65
+ end
66
+
67
+ sig { params(label: T.untyped, value: T.untyped).returns(T.nilable(String)) }
68
+ def self.entry(label, value)
69
+ values = Array(value).filter_map { |item| string_or_nil(item) }
70
+ "#{label}: #{values.join(", ")}" if values.any?
71
+ end
72
+ private_class_method :entry
73
+
74
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
75
+ def self.string_or_nil(value)
76
+ return nil if value.nil?
77
+
78
+ string = value.to_s.strip
79
+ string.empty? ? nil : string
80
+ end
81
+ private_class_method :string_or_nil
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,104 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/partial_date"
7
+
8
+ module ActiveSanction
9
+ module Sources
10
+ class UkSanctionsList < Base
11
+ # The UK Sanctions List's own date convention, which no general date
12
+ # parser reads and which is the single most valuable thing this adapter
13
+ # gets right.
14
+ #
15
+ # PublishedDate.call("04/08/2026") # => 2026-08-04, day precision
16
+ # PublishedDate.call("dd/mm/1945") # => 1945, year precision
17
+ # PublishedDate.call("dd/06/1945") # => 1945-06, month precision
18
+ # PublishedDate.call("00/00/1975") # => 1975, year precision
19
+ # PublishedDate.call("1945") # => 1945, year precision
20
+ # PublishedDate.call("15/08/19yy") # => nil
21
+ #
22
+ # ### The placeholders are the point
23
+ #
24
+ # The FCDO writes every date `DD/MM/YYYY`, and where it does not know a
25
+ # component it writes the component's own letters in its place: 800 of
26
+ # the 3,788 published birth dates read `dd/mm/1962`, and 23 read
27
+ # `dd/07/1978`. One record uses `00` for the same purpose --
28
+ # `00/00/1975`. That is a publisher stating precision, and it maps onto
29
+ # PartialDate exactly.
30
+ #
31
+ # Read with an ordinary parser those become nil, and 824 of 3,788 birth
32
+ # dates -- 22% of everything the list says about when a person was born
33
+ # -- silently disappear. Read *credulously*, as a zeroth day of a zeroth
34
+ # month, they become invalid dates or, worse, dates the scorer would
35
+ # compare as though the FCDO had been precise.
36
+ #
37
+ # ### `19yy` gets no date, on purpose
38
+ #
39
+ # One record publishes `15/08/19yy`: a day and a month, and a century
40
+ # where the year should be. PartialDate has no shape for a date with no
41
+ # year, and inventing one -- a 1900-1999 span, which would throw the day
42
+ # and month away, or a year of 1900, which would be a fact nobody
43
+ # published -- is exactly the false precision PartialDate exists to
44
+ # prevent. It reads as nil and the adapter keeps the published string in
45
+ # `remarks` instead.
46
+ #
47
+ # @api private
48
+ module PublishedDate
49
+ extend T::Sig
50
+
51
+ SEPARATOR = T.let("/", String)
52
+
53
+ # A component the FCDO filled in. Anything else -- `dd`, `mm`, `yy`,
54
+ # `00`, an empty field -- is the publisher saying it does not know,
55
+ # which is not the same as a zero and must never be read as one.
56
+ KNOWN = T.let(/\A0*[1-9]\d*\z/, Regexp)
57
+
58
+ YEAR_ONLY = T.let(/\A\d{4}\z/, Regexp)
59
+
60
+ module_function
61
+
62
+ # The PartialDate a UKSL date string states, at the precision it
63
+ # states it, or nil for one that states no year at all.
64
+ #
65
+ # Returns nil rather than raising, for the reason PartialDate::Parser
66
+ # does: a birth date is one field on a record, and a string this does
67
+ # not read should not abort the import of the entity around it. The
68
+ # adapter records what it could not read.
69
+ sig { params(text: T.untyped).returns(T.nilable(PartialDate)) }
70
+ def call(text)
71
+ string = text.to_s.strip
72
+ return nil if string.empty?
73
+ return build(year: string) if string.match?(YEAR_ONLY)
74
+
75
+ day, month, year = string.split(SEPARATOR, 3).map { |part| component(part) }
76
+ return nil if year.nil?
77
+
78
+ # A day the FCDO gave under a month it did not is a day of an unknown
79
+ # month, which PartialDate rightly refuses. The month is the part
80
+ # that is missing, so the day goes with it.
81
+ build(year: year, month: month, day: (day if month))
82
+ end
83
+
84
+ # nil for a placeholder, so that "the FCDO did not say" and "the FCDO
85
+ # said zero" cannot be confused downstream.
86
+ sig { params(part: T.untyped).returns(T.nilable(Integer)) }
87
+ def component(part)
88
+ string = part.to_s.strip
89
+ string.match?(KNOWN) ? string.to_i : nil
90
+ end
91
+
92
+ # A shape these regexes accept can still be an impossible date -- the
93
+ # list publishes no 31 February today, but a screening tool should not
94
+ # be the thing that breaks on the day it does.
95
+ sig { params(attributes: T.untyped).returns(T.nilable(PartialDate)) }
96
+ def build(**attributes)
97
+ PartialDate.new(**attributes)
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end