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,356 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/configuration"
|
|
7
|
+
require "active_sanction/doctor/finding"
|
|
8
|
+
require "active_sanction/doctor/profile"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
class Doctor
|
|
12
|
+
# One source's fresh profile held against what it was last time, and
|
|
13
|
+
# against the floors its adapter committed to. Produces findings and
|
|
14
|
+
# nothing else -- it never fetches, never stores, and never decides what to
|
|
15
|
+
# do about what it found.
|
|
16
|
+
#
|
|
17
|
+
# Checkup.new(source: :ofac_sdn, observed: today, baseline: yesterday).findings
|
|
18
|
+
#
|
|
19
|
+
# ### The baseline is the last stored snapshot, not a committed number
|
|
20
|
+
#
|
|
21
|
+
# A threshold committed per adapter ("expect ~19,321 rows +/- 2,000") goes
|
|
22
|
+
# stale on its own, and the day somebody bumps it to make the build pass is
|
|
23
|
+
# the day it stops being read. The previous snapshot does not go stale,
|
|
24
|
+
# costs nothing to maintain, and catches what a fixed threshold cannot: a
|
|
25
|
+
# fill rate that has drifted from 61% to 12% is invisible to any bound
|
|
26
|
+
# wide enough to have survived three years of a list growing.
|
|
27
|
+
#
|
|
28
|
+
# Floors are the coarse backstop underneath it, for the run that has
|
|
29
|
+
# nothing to compare against -- a first sync, a new source, a store that
|
|
30
|
+
# was cleared. Without them a first run would have to either pass silently
|
|
31
|
+
# or fail loudly, and both are wrong.
|
|
32
|
+
#
|
|
33
|
+
# ### What separates a warn from an error
|
|
34
|
+
#
|
|
35
|
+
# Not the size of the number. Whether the reading can be explained by the
|
|
36
|
+
# list changing rather than by the file changing.
|
|
37
|
+
#
|
|
38
|
+
# A third of the records disappearing is a `warn`: a delisting wave looks
|
|
39
|
+
# exactly like a truncated download, and deciding automatically that it was
|
|
40
|
+
# the first is how a compliance tool ends up quietly screening against a
|
|
41
|
+
# list it has thrown half of away. A column that used to hold numbers and
|
|
42
|
+
# now holds company names is an `error`, and so is every record on a list
|
|
43
|
+
# losing a field all of them used to carry, because nothing a publisher
|
|
44
|
+
# does to *its list* produces either -- only something done to its *file*
|
|
45
|
+
# does.
|
|
46
|
+
class Checkup
|
|
47
|
+
extend T::Sig
|
|
48
|
+
|
|
49
|
+
# A field's fill rate reads better as a sentence than as a name, and the
|
|
50
|
+
# sentence is what an operator scans at three in the morning.
|
|
51
|
+
#
|
|
52
|
+
# @api private
|
|
53
|
+
FIELD_PHRASES = T.let({
|
|
54
|
+
names: "a name",
|
|
55
|
+
aliases: "an alternate name",
|
|
56
|
+
addresses: "an address",
|
|
57
|
+
identifiers: "an identifier",
|
|
58
|
+
programs: "a program",
|
|
59
|
+
remarks: "remarks",
|
|
60
|
+
dates_of_birth: "a date of birth",
|
|
61
|
+
nationalities: "a nationality"
|
|
62
|
+
}.freeze, T::Hash[Symbol, String])
|
|
63
|
+
|
|
64
|
+
# Warning classes reported per source. A parse that has gone wrong
|
|
65
|
+
# produces one warning per row, and 19,000 findings help nobody read
|
|
66
|
+
# the one that matters.
|
|
67
|
+
#
|
|
68
|
+
# @api private
|
|
69
|
+
TOP_WARNINGS = T.let(5, Integer)
|
|
70
|
+
|
|
71
|
+
sig { returns(Symbol) }
|
|
72
|
+
attr_reader :source
|
|
73
|
+
|
|
74
|
+
sig { returns(Profile) }
|
|
75
|
+
attr_reader :observed
|
|
76
|
+
|
|
77
|
+
# What this source measured last time, or nil for a run with nothing to
|
|
78
|
+
# compare against.
|
|
79
|
+
sig { returns(T.nilable(Profile)) }
|
|
80
|
+
attr_reader :baseline
|
|
81
|
+
|
|
82
|
+
# Check name to the value the adapter committed to as a lower bound. See
|
|
83
|
+
# Sources::Definition#floor.
|
|
84
|
+
sig { returns(T::Hash[Symbol, Numeric]) }
|
|
85
|
+
attr_reader :floors
|
|
86
|
+
|
|
87
|
+
# How far a measurement may move before it is worth a finding, as a share
|
|
88
|
+
# of what it was.
|
|
89
|
+
sig { returns(Float) }
|
|
90
|
+
attr_reader :tolerance
|
|
91
|
+
|
|
92
|
+
sig do
|
|
93
|
+
params(source: T.untyped, observed: Profile, baseline: T.nilable(Profile), floors: T.untyped,
|
|
94
|
+
tolerance: T.untyped).void
|
|
95
|
+
end
|
|
96
|
+
def initialize(source:, observed:, baseline: nil, floors: {}, tolerance: Configuration::DEFAULT_DOCTOR_TOLERANCE)
|
|
97
|
+
@source = T.let(source.to_sym, Symbol)
|
|
98
|
+
@observed = T.let(observed, Profile)
|
|
99
|
+
@baseline = T.let(baseline, T.nilable(Profile))
|
|
100
|
+
@floors = T.let(floors.to_h { |name, value| [name.to_sym, value] }.freeze, T::Hash[Symbol, Numeric])
|
|
101
|
+
@tolerance = T.let(Float(tolerance), Float)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Every finding, most serious first and stable within a severity so that
|
|
105
|
+
# two runs of an unchanged list produce identical output.
|
|
106
|
+
sig { returns(T::Array[Finding]) }
|
|
107
|
+
def findings
|
|
108
|
+
collected = empty_finding || (columns + record_count + fill + remarks_coverage + warnings + orphans)
|
|
109
|
+
collected.sort_by { |finding| [-Finding::SEVERITIES.index(finding.severity).to_i, finding.check.to_s] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# A list that parsed to nothing is the one reading that stops every other
|
|
115
|
+
# check being worth running: every fill rate is 0% and every one of them
|
|
116
|
+
# would be reported as its own collapse.
|
|
117
|
+
sig { returns(T.nilable(T::Array[Finding])) }
|
|
118
|
+
def empty_finding
|
|
119
|
+
return nil unless observed.empty?
|
|
120
|
+
|
|
121
|
+
[finding(:error, :empty, "parsed to no records at all", observed: 0, baseline: baseline&.record_count)]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A positional column holding something other than what it held is the
|
|
125
|
+
# one finding here that needs no baseline: it is an assertion about the
|
|
126
|
+
# file, and a file that fails it is not the file this adapter reads.
|
|
127
|
+
sig { returns(T::Array[Finding]) }
|
|
128
|
+
def columns
|
|
129
|
+
observed.columns.reject { |column| column[:ratio].to_f >= column[:at_least].to_f }.map do |column|
|
|
130
|
+
finding(:error, :"column_#{column[:column]}", column_message(column),
|
|
131
|
+
observed: column[:ratio], baseline: column[:at_least])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { params(column: T::Hash[Symbol, T.untyped]).returns(String) }
|
|
136
|
+
def column_message(column)
|
|
137
|
+
sample = Array(column[:sample])
|
|
138
|
+
"#{column[:column]} #{column[:description]} on #{percentage(column[:ratio].to_f)} of " \
|
|
139
|
+
"#{number(column[:checked].to_i)} rows, expected #{percentage(column[:at_least].to_f)}" \
|
|
140
|
+
"#{": #{sample.map(&:inspect).join(", ")}" if sample.any?}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
sig { returns(T::Array[Finding]) }
|
|
144
|
+
def record_count
|
|
145
|
+
now = observed.record_count
|
|
146
|
+
was = baseline&.record_count
|
|
147
|
+
return record_count_floor(now) if was.nil?
|
|
148
|
+
return [] if within?(now, was)
|
|
149
|
+
|
|
150
|
+
[finding(now < was ? :warn : :info, :record_count,
|
|
151
|
+
"#{number(now)} records, was #{number(was)} (#{movement(now, was)})",
|
|
152
|
+
observed: now, baseline: was)]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
sig { params(now: Integer).returns(T::Array[Finding]) }
|
|
156
|
+
def record_count_floor(now)
|
|
157
|
+
floor_finding(:record_count, now) do |floor|
|
|
158
|
+
"#{number(now)} records, below the floor of #{number(floor.to_i)}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The check that catches a file which changed shape and still parses. See
|
|
163
|
+
# Profile.
|
|
164
|
+
sig { returns(T::Array[Finding]) }
|
|
165
|
+
def fill
|
|
166
|
+
observed.fill.flat_map do |field, now|
|
|
167
|
+
was = baseline&.fill&.[](field)
|
|
168
|
+
next fill_floor(field, now) if was.nil?
|
|
169
|
+
|
|
170
|
+
fill_movement(field, now, was)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sig { params(field: Symbol, now: Float).returns(T::Array[Finding]) }
|
|
175
|
+
def fill_floor(field, now)
|
|
176
|
+
floor_finding(:"fill_#{field}", now) { |floor| fill_floor_message(field, now, floor) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
sig { params(field: Symbol, now: Float, was: Float).returns(T::Array[Finding]) }
|
|
180
|
+
def fill_movement(field, now, was)
|
|
181
|
+
return lost(field, was) if now.zero? && was.positive?
|
|
182
|
+
return [] if within?(now, was)
|
|
183
|
+
|
|
184
|
+
[finding(now < was ? :warn : :info, :"fill_#{field}", fill_message(field, now, was),
|
|
185
|
+
observed: now, baseline: was)]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Every record on the list losing a field all of them carried. The record
|
|
189
|
+
# count is unchanged, nothing raised, and the list now means something
|
|
190
|
+
# different -- which is the failure this whole diagnostic exists for.
|
|
191
|
+
sig { params(field: Symbol, was: Float).returns(T::Array[Finding]) }
|
|
192
|
+
def lost(field, was)
|
|
193
|
+
[finding(:error, :"fill_#{field}",
|
|
194
|
+
"no #{singular(field)} carries #{phrase(field)} any more, #{percentage(was)} did",
|
|
195
|
+
observed: 0.0, baseline: was)]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
sig { params(field: Symbol, now: Float, was: Float).returns(String) }
|
|
199
|
+
def fill_message(field, now, was)
|
|
200
|
+
"#{observed.cohort_name(field)} with #{phrase(field)} #{percentage(now)} (was #{percentage(was)}) " \
|
|
201
|
+
"of #{number(observed.cohort_size(field))}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
sig { params(field: Symbol, now: Float, floor: Numeric).returns(String) }
|
|
205
|
+
def fill_floor_message(field, now, floor)
|
|
206
|
+
"#{observed.cohort_name(field)} with #{phrase(field)} #{percentage(now)}, " \
|
|
207
|
+
"below the floor of #{percentage(floor.to_f)}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# How much of the publisher's free text the parser understood. The one
|
|
211
|
+
# measurement here that moves when a publisher re-spells a label rather
|
|
212
|
+
# than when it changes a format, which is the most common way one of
|
|
213
|
+
# these lists quietly stops yielding passports.
|
|
214
|
+
sig { returns(T::Array[Finding]) }
|
|
215
|
+
def remarks_coverage
|
|
216
|
+
now = observed.remarks_coverage
|
|
217
|
+
return [] if now.nil?
|
|
218
|
+
|
|
219
|
+
was = baseline&.remarks_coverage
|
|
220
|
+
return floor_finding(:remarks_coverage, now) { |floor| coverage_message(now, nil, floor) } if was.nil?
|
|
221
|
+
return [] if within?(now, was)
|
|
222
|
+
|
|
223
|
+
[finding(now < was ? :warn : :info, :remarks_coverage, coverage_message(now, was, nil),
|
|
224
|
+
observed: now, baseline: was)]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
sig { params(now: Float, was: T.nilable(Float), floor: T.nilable(Numeric)).returns(String) }
|
|
228
|
+
def coverage_message(now, was, floor)
|
|
229
|
+
against = was.nil? ? "below the floor of #{percentage(T.must(floor).to_f)}" : "was #{percentage(was)}"
|
|
230
|
+
"remarks coverage #{percentage(now)} (#{against})#{unrecognized}"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The shape that cost the most segments, which is the new spelling if
|
|
234
|
+
# there is one.
|
|
235
|
+
sig { returns(String) }
|
|
236
|
+
def unrecognized
|
|
237
|
+
worst = observed.worst_unrecognized
|
|
238
|
+
worst.nil? ? "" : ": #{worst.first.inspect} x #{number(worst.last.to_i)} unrecognized"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A warning class that was not there last time is the signal; the
|
|
242
|
+
# absolute count is not, because these files always carry a few rows
|
|
243
|
+
# nobody can read.
|
|
244
|
+
sig { returns(T::Array[Finding]) }
|
|
245
|
+
def warnings
|
|
246
|
+
return [] if observed.warnings.nil?
|
|
247
|
+
|
|
248
|
+
previous = baseline&.warnings
|
|
249
|
+
observed.top_warnings(TOP_WARNINGS).map do |shape, rows|
|
|
250
|
+
was = previous&.fetch(shape, 0)
|
|
251
|
+
share = rows.fdiv([observed.record_count, 1].max)
|
|
252
|
+
warning_finding(warning_severity(share, was), shape, rows, was)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Judged as a share of the list rather than as a count, so that a
|
|
257
|
+
# complaint about 41 rows of 19,321 stays informational whichever list it
|
|
258
|
+
# is on. New since the last sync is what raises it: a class that was
|
|
259
|
+
# absent and is now on a tenth of the file is a format change.
|
|
260
|
+
sig { params(share: Float, was: T.nilable(Integer)).returns(Symbol) }
|
|
261
|
+
def warning_severity(share, was)
|
|
262
|
+
return :info if share < tolerance
|
|
263
|
+
|
|
264
|
+
was.nil? || was.zero? ? :warn : :info
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
sig { params(severity: Symbol, shape: String, rows: Integer, was: T.nilable(Integer)).returns(Finding) }
|
|
268
|
+
def warning_finding(severity, shape, rows, was)
|
|
269
|
+
history = if was.nil?
|
|
270
|
+
""
|
|
271
|
+
else
|
|
272
|
+
was.zero? ? ", new since the last sync" : ", was #{number(was)}"
|
|
273
|
+
end
|
|
274
|
+
finding(severity, :warnings, "#{shape} (#{number(rows)} rows#{history})", observed: rows, baseline: was)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Child rows that matched no parent. On OFAC's three-file join a nonzero
|
|
278
|
+
# count means the files were downloaded at different moments; it is
|
|
279
|
+
# informational because that resolves itself on the next sync, and worth
|
|
280
|
+
# saying because a join that has stopped joining does not.
|
|
281
|
+
sig { returns(T::Array[Finding]) }
|
|
282
|
+
def orphans
|
|
283
|
+
counts = observed.orphans
|
|
284
|
+
return [] if counts.nil? || observed.orphan_count.zero?
|
|
285
|
+
|
|
286
|
+
total = observed.orphan_count
|
|
287
|
+
share = total.fdiv([observed.record_count, 1].max)
|
|
288
|
+
detail = counts.reject { |_file, rows| rows.zero? }.map { |file, rows| "#{number(rows)} in #{file}" }
|
|
289
|
+
[finding(share > tolerance ? :warn : :info, :orphans,
|
|
290
|
+
"#{number(total)} child row(s) matched no record: #{detail.join(", ")}",
|
|
291
|
+
observed: total, baseline: baseline&.orphan_count)]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# The coarse backstop, and only where the adapter committed to one.
|
|
295
|
+
# Silence for a check with no floor is deliberate: a first run reporting
|
|
296
|
+
# every unmeasurable thing as a problem is a first run nobody reads.
|
|
297
|
+
sig do
|
|
298
|
+
params(check: Symbol, value: Numeric, block: T.proc.params(floor: Numeric).returns(String))
|
|
299
|
+
.returns(T::Array[Finding])
|
|
300
|
+
end
|
|
301
|
+
def floor_finding(check, value, &block)
|
|
302
|
+
floor = floors[check]
|
|
303
|
+
return [] if floor.nil? || value >= floor
|
|
304
|
+
|
|
305
|
+
[finding(:warn, check, block.call(floor), observed: value, baseline: nil)]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Whether a reading moved far enough from what it was to be worth saying,
|
|
309
|
+
# as a share of what it was. A baseline of zero has no share to be a
|
|
310
|
+
# fraction of, so anything above it counts as movement.
|
|
311
|
+
sig { params(now: Numeric, was: Numeric).returns(T::Boolean) }
|
|
312
|
+
def within?(now, was)
|
|
313
|
+
return now == was if was.zero?
|
|
314
|
+
|
|
315
|
+
(now - was).abs.fdiv(was) <= tolerance
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
sig { params(now: Numeric, was: Numeric).returns(String) }
|
|
319
|
+
def movement(now, was)
|
|
320
|
+
share = (now - was).abs.fdiv(was).to_f
|
|
321
|
+
"#{now < was ? "down" : "up"} #{percentage(share)}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
sig do
|
|
325
|
+
params(severity: Symbol, check: Symbol, message: String, observed: T.untyped, baseline: T.untyped)
|
|
326
|
+
.returns(Finding)
|
|
327
|
+
end
|
|
328
|
+
def finding(severity, check, message, observed: nil, baseline: nil)
|
|
329
|
+
Finding.new(source: source, severity: severity, check: check, message: message,
|
|
330
|
+
observed: observed, baseline: baseline)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
sig { params(field: Symbol).returns(String) }
|
|
334
|
+
def phrase(field) = FIELD_PHRASES.fetch(field, field.to_s.tr("_", " "))
|
|
335
|
+
|
|
336
|
+
# "no individual carries a date of birth any more" -- the cohort name in
|
|
337
|
+
# the singular, which is the only place a fill message needs one.
|
|
338
|
+
sig { params(field: Symbol).returns(String) }
|
|
339
|
+
def singular(field)
|
|
340
|
+
name = observed.cohort_name(field)
|
|
341
|
+
name.end_with?("s") ? T.must(name[0..-2]) : name
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
sig { params(ratio: Float).returns(String) }
|
|
345
|
+
def percentage(ratio)
|
|
346
|
+
value = (ratio * 100).round(1)
|
|
347
|
+
value == value.to_i ? "#{value.to_i}%" : "#{value}%"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Thousands separated, because the numbers this reports are list-sized
|
|
351
|
+
# and "19321" and "1932" are one glance apart.
|
|
352
|
+
sig { params(value: Integer).returns(String) }
|
|
353
|
+
def number(value) = value.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/doctor/finding"
|
|
8
|
+
require "active_sanction/doctor/profile"
|
|
9
|
+
|
|
10
|
+
module ActiveSanction
|
|
11
|
+
class Doctor
|
|
12
|
+
# What the doctor found out about one source.
|
|
13
|
+
#
|
|
14
|
+
# diagnosis.source # => :ofac_sdn
|
|
15
|
+
# diagnosis.ok? # => false
|
|
16
|
+
# diagnosis.severity # => :warn
|
|
17
|
+
# diagnosis.findings # => [Finding, ...]
|
|
18
|
+
# diagnosis.profile # => Profile, what this run measured
|
|
19
|
+
# diagnosis.compared? # => true, there was a previous list to compare with
|
|
20
|
+
#
|
|
21
|
+
# Two statuses, and they are about the diagnosis rather than about the
|
|
22
|
+
# list:
|
|
23
|
+
#
|
|
24
|
+
# :checked the list was fetched, parsed and measured
|
|
25
|
+
# :failed it could not be, and the exception is the finding
|
|
26
|
+
#
|
|
27
|
+
# A source that fails to fetch is not a source in good health, but nor is
|
|
28
|
+
# it one this can say anything about -- which is why the failure is
|
|
29
|
+
# recorded as an `error` finding and the profile is nil, rather than a
|
|
30
|
+
# profile of nothing being compared against the last good one and reported
|
|
31
|
+
# as every field collapsing at once.
|
|
32
|
+
#
|
|
33
|
+
# Nothing here is stored. A diagnosis is what the doctor returns, and
|
|
34
|
+
# keeping it -- to compare a warning class against next week, to graph a
|
|
35
|
+
# fill rate -- is the host application's decision, which is why #to_h
|
|
36
|
+
# serializes the profile along with the findings.
|
|
37
|
+
#
|
|
38
|
+
# Instances are frozen on construction and compare by value.
|
|
39
|
+
class Diagnosis
|
|
40
|
+
extend T::Sig
|
|
41
|
+
|
|
42
|
+
# Whether the doctor got far enough to have an opinion. `failed` means
|
|
43
|
+
# the list could not be read at all, which is a different report from
|
|
44
|
+
# one that read it and found something wrong with it.
|
|
45
|
+
STATUSES = T.let(%i[checked failed].freeze, T::Array[Symbol])
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
MEMBERS = T.let(%i[source status findings profile baseline duration error].freeze, T::Array[Symbol])
|
|
49
|
+
|
|
50
|
+
sig { returns(Symbol) }
|
|
51
|
+
attr_reader :source
|
|
52
|
+
|
|
53
|
+
sig { returns(Symbol) }
|
|
54
|
+
attr_reader :status
|
|
55
|
+
|
|
56
|
+
sig { returns(T::Array[Finding]) }
|
|
57
|
+
attr_reader :findings
|
|
58
|
+
|
|
59
|
+
# What this run measured, or nil for a source that could not be read.
|
|
60
|
+
sig { returns(T.nilable(Profile)) }
|
|
61
|
+
attr_reader :profile
|
|
62
|
+
|
|
63
|
+
# What it was measured against: the profile of the snapshot in storage,
|
|
64
|
+
# or the one a caller kept from the last run. nil when there was nothing
|
|
65
|
+
# to compare with, which is what makes the difference between "this is
|
|
66
|
+
# the first look" and "nothing changed".
|
|
67
|
+
sig { returns(T.nilable(Profile)) }
|
|
68
|
+
attr_reader :baseline
|
|
69
|
+
|
|
70
|
+
sig { returns(Float) }
|
|
71
|
+
attr_reader :duration
|
|
72
|
+
|
|
73
|
+
# The exception behind a `:failed` diagnosis, for a caller that wants the
|
|
74
|
+
# backtrace. nil after a round-trip through #to_h, exactly as
|
|
75
|
+
# Sync::Result has it.
|
|
76
|
+
sig { returns(T.nilable(Exception)) }
|
|
77
|
+
attr_reader :exception
|
|
78
|
+
|
|
79
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
80
|
+
def self.from_h(hash)
|
|
81
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
82
|
+
unknown = attributes.keys - MEMBERS
|
|
83
|
+
raise InvalidArgument, "unknown Doctor::Diagnosis attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
84
|
+
|
|
85
|
+
T.unsafe(self).new(**attributes)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig do
|
|
89
|
+
params(source: T.untyped, status: T.untyped, findings: T.untyped, profile: T.untyped,
|
|
90
|
+
baseline: T.untyped, duration: T.untyped, error: T.untyped).void
|
|
91
|
+
end
|
|
92
|
+
def initialize(source:, status:, findings: [], profile: nil, baseline: nil, duration: 0.0, error: nil)
|
|
93
|
+
@source = T.let(symbol!(:source, source), Symbol)
|
|
94
|
+
@status = T.let(status!(status), Symbol)
|
|
95
|
+
@findings = T.let(findings!(findings), T::Array[Finding])
|
|
96
|
+
@profile = T.let(profile!(profile), T.nilable(Profile))
|
|
97
|
+
@baseline = T.let(profile!(baseline), T.nilable(Profile))
|
|
98
|
+
@duration = T.let(duration.to_f, Float)
|
|
99
|
+
@exception = T.let(error.is_a?(Exception) ? error : nil, T.nilable(Exception))
|
|
100
|
+
@failure = T.let(failure!(error), T.nilable(T::Hash[Symbol, String]))
|
|
101
|
+
freeze
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { returns(T::Boolean) }
|
|
105
|
+
def checked? = status == :checked
|
|
106
|
+
|
|
107
|
+
sig { returns(T::Boolean) }
|
|
108
|
+
def failed? = status == :failed
|
|
109
|
+
|
|
110
|
+
# Whether there was a previous list to measure this one against. A run
|
|
111
|
+
# with no baseline is held to the adapter's committed floors instead, and
|
|
112
|
+
# says so rather than treating a first look as a regression.
|
|
113
|
+
sig { returns(T::Boolean) }
|
|
114
|
+
def compared? = !baseline.nil?
|
|
115
|
+
|
|
116
|
+
# Nothing above `info`. The question the CLI's `OK` answers, and the one
|
|
117
|
+
# a nightly job alerts on.
|
|
118
|
+
sig { returns(T::Boolean) }
|
|
119
|
+
def ok? = findings.none? { |finding| finding.at_least?(:warn) }
|
|
120
|
+
|
|
121
|
+
# The most serious severity found, or nil for a source with nothing to
|
|
122
|
+
# say about it at all.
|
|
123
|
+
sig { returns(T.nilable(Symbol)) }
|
|
124
|
+
def severity
|
|
125
|
+
Finding::SEVERITIES.reverse.find { |level| findings.any? { |finding| finding.severity == level } }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sig { returns(T::Array[Finding]) }
|
|
129
|
+
def errors = findings.select(&:error?)
|
|
130
|
+
|
|
131
|
+
sig { returns(T::Array[Finding]) }
|
|
132
|
+
def warnings = findings.select(&:warn?)
|
|
133
|
+
|
|
134
|
+
sig { returns(T::Array[Finding]) }
|
|
135
|
+
def infos = findings.select(&:info?)
|
|
136
|
+
|
|
137
|
+
sig { returns(T.nilable(String)) }
|
|
138
|
+
def error_class = @failure&.fetch(:class)
|
|
139
|
+
|
|
140
|
+
sig { returns(T.nilable(String)) }
|
|
141
|
+
def error_message = @failure&.fetch(:message)
|
|
142
|
+
|
|
143
|
+
# The failure on one line, for a log or a table. nil when nothing failed.
|
|
144
|
+
sig { returns(T.nilable(String)) }
|
|
145
|
+
def error
|
|
146
|
+
return nil unless error_class
|
|
147
|
+
|
|
148
|
+
message = error_message
|
|
149
|
+
return error_class if message.nil? || message.empty? || message == error_class
|
|
150
|
+
|
|
151
|
+
"#{error_class}: #{message}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sig { returns(Integer) }
|
|
155
|
+
def record_count = profile&.record_count || 0
|
|
156
|
+
|
|
157
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
158
|
+
def to_h
|
|
159
|
+
{ source: source, status: status, findings: findings.map(&:to_h), profile: profile&.to_h,
|
|
160
|
+
baseline: baseline&.to_h, duration: duration, error: @failure }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The heading a source gets in the report, followed by its findings:
|
|
164
|
+
#
|
|
165
|
+
# ofac_sdn WARN 3 findings
|
|
166
|
+
# un_consolidated OK
|
|
167
|
+
sig { params(width: Integer).returns(String) }
|
|
168
|
+
def headline(width = 0)
|
|
169
|
+
heading = "#{source.to_s.ljust(width)} #{label}"
|
|
170
|
+
return heading if findings.empty?
|
|
171
|
+
|
|
172
|
+
"#{heading} #{findings.size} finding#{"s" unless findings.size == 1}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# This source's block of the report: its heading, then one line per
|
|
176
|
+
# finding. `width` is how wide the source column is across the whole
|
|
177
|
+
# run, so that the labels line up in a column an eye can run down.
|
|
178
|
+
sig { params(width: Integer).returns(T::Array[String]) }
|
|
179
|
+
def lines(width = 0) = [headline(width)] + findings.map(&:to_line)
|
|
180
|
+
|
|
181
|
+
sig { returns(String) }
|
|
182
|
+
def to_s = lines.join("\n")
|
|
183
|
+
|
|
184
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
185
|
+
def ==(other)
|
|
186
|
+
return false unless other.instance_of?(self.class)
|
|
187
|
+
|
|
188
|
+
to_h == other.to_h
|
|
189
|
+
end
|
|
190
|
+
alias eql? ==
|
|
191
|
+
|
|
192
|
+
sig { returns(Integer) }
|
|
193
|
+
def hash = [self.class, to_h].hash
|
|
194
|
+
|
|
195
|
+
sig { returns(String) }
|
|
196
|
+
def inspect = "#<#{self.class} #{source} #{label} #{findings.size} finding(s)>"
|
|
197
|
+
|
|
198
|
+
# `OK`, `INFO`, `WARN` or `ERROR` -- what an operator's eye goes down the
|
|
199
|
+
# left-hand column looking for.
|
|
200
|
+
sig { returns(String) }
|
|
201
|
+
def label = (severity || :ok).to_s.upcase
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
206
|
+
def status!(value)
|
|
207
|
+
status = symbol!(:status, value)
|
|
208
|
+
return status if STATUSES.include?(status)
|
|
209
|
+
|
|
210
|
+
raise InvalidArgument, "status must be one of #{STATUSES.join(", ")}, got #{value.inspect}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
sig { params(value: T.untyped).returns(T::Array[Finding]) }
|
|
214
|
+
def findings!(value)
|
|
215
|
+
Array(value).map { |finding| finding.is_a?(Finding) ? finding : Finding.from_h(finding) }.freeze
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
sig { params(value: T.untyped).returns(T.nilable(Profile)) }
|
|
219
|
+
def profile!(value)
|
|
220
|
+
return nil if value.nil?
|
|
221
|
+
|
|
222
|
+
value.is_a?(Profile) ? value : Profile.from_h(value)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
sig { params(value: T.untyped).returns(T.nilable(T::Hash[Symbol, String])) }
|
|
226
|
+
def failure!(value)
|
|
227
|
+
case value
|
|
228
|
+
when nil then nil
|
|
229
|
+
when Exception then { class: value.class.name.to_s, message: value.message.to_s }.freeze
|
|
230
|
+
else
|
|
231
|
+
pair = value.to_h.transform_keys(&:to_sym)
|
|
232
|
+
name = pair[:class]&.to_s
|
|
233
|
+
name && !name.empty? ? { class: -name, message: pair[:message].to_s }.freeze : nil
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
sig { params(member: Symbol, value: T.untyped).returns(Symbol) }
|
|
238
|
+
def symbol!(member, value)
|
|
239
|
+
raise InvalidArgument, "#{member} is required" if value.nil? || value.to_s.empty?
|
|
240
|
+
|
|
241
|
+
value.to_sym
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|