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,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