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,227 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "time"
7
+ require "active_sanction/error"
8
+ require "active_sanction/doctor/diagnosis"
9
+ require "active_sanction/doctor/finding"
10
+
11
+ module ActiveSanction
12
+ class Doctor
13
+ # What a whole diagnostic run found, one Diagnosis per source.
14
+ #
15
+ # report = ActiveSanction.doctor
16
+ #
17
+ # report.ok? # => false
18
+ # report.findings # => [Finding, ...]
19
+ # report[:ofac_sdn] # => Diagnosis
20
+ # exit report.exit_code
21
+ # puts report
22
+ #
23
+ # 2 sources in 18.42s: 1 with findings
24
+ # ofac_sdn WARN 3 findings
25
+ # warn remarks coverage 71.4% (was 97.3%): "Passport No." x 1,880 unrecognized
26
+ # warn individuals with a date of birth 12% (was 61%) of 11,704
27
+ # info unknown SDN_Type "syndicate"; treated as an organization (41 rows)
28
+ # un_consolidated OK
29
+ #
30
+ # ### It is an object, not console output
31
+ #
32
+ # The same split Sync::Report makes, for the same reason. The human form is
33
+ # what a CLI verb (#36) prints; the serialized form is what a host
34
+ # application alerts on, what a nightly job keeps so that next week's run
35
+ # has a warning class to compare against, and what the instrumentation
36
+ # hooks (#59) emit. A diagnostic that only existed as printed text would
37
+ # mean every host that wants to notice a drifting source has to scrape a
38
+ # log, which is precisely the state this exists to end.
39
+ #
40
+ # ### The exit code is a policy, and it is the caller's
41
+ #
42
+ # `exit_code` is 1 when anything failed at `error`, because a list that
43
+ # cannot be read is not a matter of taste. Whether a `warn` should also
44
+ # stop a deployment is, so it is a parameter: `exit_code(on: :warn)` is
45
+ # what a team that treats drift as a build failure passes.
46
+ #
47
+ # Instances are frozen on construction and compare by value.
48
+ class Report
49
+ extend T::Sig
50
+ extend T::Generic
51
+ include Enumerable
52
+
53
+ # @api private
54
+ Elem = type_member { { fixed: Diagnosis } }
55
+
56
+ # @api private
57
+ MEMBERS = T.let(%i[diagnoses started_at duration].freeze, T::Array[Symbol])
58
+
59
+ sig { returns(T::Array[Diagnosis]) }
60
+ attr_reader :diagnoses
61
+
62
+ # When the run began, UTC.
63
+ sig { returns(Time) }
64
+ attr_reader :started_at
65
+
66
+ # Wall-clock seconds for the whole run.
67
+ sig { returns(Float) }
68
+ attr_reader :duration
69
+
70
+ sig { params(hash: T.untyped).returns(T.attached_class) }
71
+ def self.from_h(hash)
72
+ attributes = hash.to_h.transform_keys(&:to_sym)
73
+ unknown = attributes.keys - MEMBERS
74
+ raise InvalidArgument, "unknown Doctor::Report attribute(s): #{unknown.join(", ")}" if unknown.any?
75
+
76
+ T.unsafe(self).new(**attributes)
77
+ end
78
+
79
+ sig { params(diagnoses: T.untyped, started_at: T.untyped, duration: T.untyped).void }
80
+ def initialize(diagnoses:, started_at: nil, duration: 0.0)
81
+ @diagnoses = T.let(diagnoses!(diagnoses), T::Array[Diagnosis])
82
+ @started_at = T.let(time!(started_at), Time)
83
+ @duration = T.let(duration.to_f, Float)
84
+ freeze
85
+ end
86
+
87
+ sig { override.params(block: T.nilable(T.proc.params(diagnosis: Diagnosis).void)).returns(T.untyped) }
88
+ def each(&block)
89
+ return enum_for(:each) unless block
90
+
91
+ diagnoses.each(&block)
92
+ self
93
+ end
94
+
95
+ # One source's diagnosis, or nil if the run did not cover it.
96
+ sig { params(source: T.untyped).returns(T.nilable(Diagnosis)) }
97
+ def [](source)
98
+ key = source.to_sym
99
+ diagnoses.find { |diagnosis| diagnosis.source == key }
100
+ end
101
+
102
+ sig { returns(T::Array[Symbol]) }
103
+ def sources = diagnoses.map(&:source)
104
+
105
+ # Every finding across every source, most serious first, and within a
106
+ # severity in the order the sources were diagnosed.
107
+ sig { returns(T::Array[Finding]) }
108
+ def findings
109
+ diagnoses.flat_map(&:findings)
110
+ .sort_by.with_index { |finding, at| [-Finding::SEVERITIES.index(finding.severity).to_i, at] }
111
+ end
112
+
113
+ sig { returns(T::Array[Finding]) }
114
+ def errors = findings.select(&:error?)
115
+
116
+ sig { returns(T::Array[Finding]) }
117
+ def warnings = findings.select(&:warn?)
118
+
119
+ sig { returns(T::Array[Finding]) }
120
+ def infos = findings.select(&:info?)
121
+
122
+ # Nothing above `info`, anywhere. What a nightly job alerts on when it
123
+ # only wants one question answered.
124
+ sig { returns(T::Boolean) }
125
+ def ok? = diagnoses.all?(&:ok?)
126
+
127
+ # The sources with something worth reading about them.
128
+ sig { returns(T::Array[Diagnosis]) }
129
+ def unhealthy = diagnoses.reject(&:ok?)
130
+
131
+ # The sources that could not be diagnosed at all -- a publisher that is
132
+ # down, a payload that is not the format it should be. Louder than a
133
+ # finding, and a different question: nothing here knows whether those
134
+ # lists have drifted.
135
+ sig { returns(T::Array[Diagnosis]) }
136
+ def failed = diagnoses.select(&:failed?)
137
+
138
+ sig { returns(T::Boolean) }
139
+ def failed? = diagnoses.any?(&:failed?)
140
+
141
+ # The most serious severity anywhere in the run, or nil for a clean one.
142
+ sig { returns(T.nilable(Symbol)) }
143
+ def severity
144
+ Finding::SEVERITIES.reverse.find { |level| diagnoses.any? { |one| one.severity == level } }
145
+ end
146
+
147
+ sig { returns(Integer) }
148
+ def size = diagnoses.size
149
+
150
+ sig { returns(T::Boolean) }
151
+ def empty? = diagnoses.empty?
152
+
153
+ # What a scheduled job should exit with. 1 on any `error` by default, and
154
+ # `on: :warn` for a caller that wants drift to stop a build too. See the
155
+ # class comment.
156
+ sig { params(on: T.untyped).returns(Integer) }
157
+ def exit_code(on: :error)
158
+ level = on.to_sym
159
+ findings.any? { |finding| finding.at_least?(level) } ? 1 : 0
160
+ end
161
+
162
+ sig { returns(T::Hash[Symbol, T.untyped]) }
163
+ def to_h
164
+ { diagnoses: diagnoses.map(&:to_h), started_at: started_at.iso8601, duration: duration }
165
+ end
166
+
167
+ # The profile of each source, keyed by source -- what a nightly job keeps
168
+ # so that the next run has last night's warning classes and free-text
169
+ # coverage to compare against, which a stored snapshot cannot supply.
170
+ # See Doctor#baseline.
171
+ sig { returns(T::Hash[Symbol, Profile]) }
172
+ def profiles
173
+ diagnoses.each_with_object({}) do |diagnosis, all|
174
+ profile = diagnosis.profile
175
+ all[diagnosis.source] = profile unless profile.nil?
176
+ end
177
+ end
178
+
179
+ sig { returns(String) }
180
+ def summary
181
+ counts = { "with findings" => unhealthy.size, "unreadable" => failed.size }
182
+ .reject { |_label, count| count.zero? }
183
+ .map { |label, count| "#{count} #{label}" }
184
+ "#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
185
+ "#{": #{counts.empty? ? "all healthy" : counts.join(", ")}"}"
186
+ end
187
+
188
+ sig { returns(String) }
189
+ def to_s = ([summary] + diagnoses.flat_map { |diagnosis| diagnosis.lines(width) }).join("\n")
190
+
191
+ sig { params(other: T.untyped).returns(T::Boolean) }
192
+ def ==(other)
193
+ return false unless other.instance_of?(self.class)
194
+
195
+ to_h == other.to_h
196
+ end
197
+ alias eql? ==
198
+
199
+ sig { returns(Integer) }
200
+ def hash = [self.class, to_h].hash
201
+
202
+ sig { returns(String) }
203
+ def inspect = "#<#{self.class} #{summary}>"
204
+
205
+ private
206
+
207
+ sig { returns(Integer) }
208
+ def width = diagnoses.map { |diagnosis| diagnosis.source.to_s.length }.max.to_i
209
+
210
+ sig { params(value: T.untyped).returns(T::Array[Diagnosis]) }
211
+ def diagnoses!(value)
212
+ Array(value).map { |one| one.is_a?(Diagnosis) ? one : Diagnosis.from_h(one) }.freeze
213
+ end
214
+
215
+ sig { params(value: T.untyped).returns(Time) }
216
+ def time!(value)
217
+ time = case value
218
+ when nil then Time.now
219
+ when Time then value
220
+ when String then Time.parse(value)
221
+ else raise InvalidArgument, "started_at is not a time: #{value.inspect}"
222
+ end
223
+ Time.at(time.to_i).utc
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,347 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "active_sanction/error"
7
+ require "active_sanction/fetcher"
8
+ require "active_sanction/sources"
9
+ require "active_sanction/storage"
10
+ require "active_sanction/validator_store"
11
+ require "active_sanction/doctor/checkup"
12
+ require "active_sanction/doctor/diagnosis"
13
+ require "active_sanction/doctor/finding"
14
+ require "active_sanction/doctor/profile"
15
+ require "active_sanction/doctor/report"
16
+
17
+ module ActiveSanction
18
+ # Diagnoses whether a source's format has drifted: fetches each list, parses
19
+ # it, measures it, and compares the measurements against the last version
20
+ # that was stored.
21
+ #
22
+ # report = ActiveSanction.doctor # every configured source
23
+ # report = ActiveSanction.doctor(:ofac_sdn) # one
24
+ #
25
+ # report.ok? # => false
26
+ # report.findings # => [Finding(source:, severity:, check:, message:, observed:, baseline:)]
27
+ # exit report.exit_code
28
+ #
29
+ # ### The failure this exists to catch
30
+ #
31
+ # Sanctions lists change format on three clocks. A whole-format migration is
32
+ # announced years ahead and fails loudly. A column added or an element
33
+ # renamed happens quietly, in months. A new document label or a new
34
+ # designation vocabulary happens continuously, weekly.
35
+ #
36
+ # Only the first of those fails loudly. The dangerous ones are the changes
37
+ # where the file still parses cleanly and means something different: 19,321
38
+ # entities carrying zero passports looks exactly as healthy as 19,321
39
+ # carrying 23,429 if all anyone counts is records. Nothing in a sync would
40
+ # notice that for months, and a screening run against it returns a clean
41
+ # result for a customer whose passport is on the list.
42
+ #
43
+ # So this measures what a sync does not: the share of records carrying each
44
+ # field, the vocabulary the parser recognized, the shape of the values in a
45
+ # positional column, the classes of warning the parse produced. See Profile
46
+ # for what is measured and Checkup for what is made of it.
47
+ #
48
+ # ### It never writes anything
49
+ #
50
+ # Not the snapshot, not the payload cache, not the conditional-GET
51
+ # validators. Each adapter the doctor builds gets a fetcher over an in-memory
52
+ # validator store and no payload cache, which has two consequences worth
53
+ # stating:
54
+ #
55
+ # - every run downloads every list in full, because a diagnosis of a list the
56
+ # publisher answered 304 for is a diagnosis of nothing; and
57
+ # - a doctor run before a sync cannot make that sync skip a changed list.
58
+ # Sharing the validators would do exactly that -- the doctor's fetch would
59
+ # learn the new ETag, the sync that followed would be answered 304, and the
60
+ # list it decided was unchanged would be the one the doctor had just seen
61
+ # change. Diagnosing a source must not be able to stop it being updated.
62
+ #
63
+ # Nothing is repaired either. Deciding that a 40% drop in record count is a
64
+ # delisting wave rather than a broken parse is a judgment call, and making it
65
+ # automatically is how a compliance tool ends up quietly screening against
66
+ # nothing.
67
+ #
68
+ # It is not a cheap run, and it is not meant to be: every list is downloaded,
69
+ # parsed, and parsed again where a positional file's columns are asserted,
70
+ # and the stored snapshot is read in full so its fill rates can be recomputed
71
+ # as the baseline. That is the price of comparing two parses rather than two
72
+ # file sizes, and it is charged once a night rather than once a sync.
73
+ #
74
+ # ### One source failing does not stop the others
75
+ #
76
+ # The same rule sync orchestration runs under, and for the same reason:
77
+ # government endpoints go down, and a UN outage must not stop OFAC being
78
+ # diagnosed. Each source runs inside its own rescue and a failure becomes an
79
+ # `error` finding on that source alone.
80
+ #
81
+ # ### Where this is meant to run
82
+ #
83
+ # In a nightly job, not in a terminal. A `doctor` invoked by hand only
84
+ # confirms a regression that was already suspected; the whole value here is
85
+ # noticing one nobody suspected, which means something has to run it when
86
+ # nobody is looking and alert when it says something. `exit_code` is for the
87
+ # cron job, `to_h` is for the metrics pipeline, and `to_s` is for the CLI
88
+ # verb (#36) that will print it.
89
+ class Doctor
90
+ extend T::Sig
91
+
92
+ # The adapters this run covers.
93
+ sig { returns(T::Array[T.untyped]) }
94
+ attr_reader :sources
95
+
96
+ sig { returns(T::Array[Symbol]) }
97
+ attr_reader :keys
98
+
99
+ sig { returns(T.untyped) }
100
+ attr_reader :store
101
+
102
+ # How far a measurement may move from its baseline before it is worth a
103
+ # finding, as a share of what it was.
104
+ sig { returns(Float) }
105
+ attr_reader :tolerance
106
+
107
+ sig { returns(T.untyped) }
108
+ attr_reader :logger
109
+
110
+ sig { params(options: T.untyped, block: T.untyped).returns(Report) }
111
+ def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
112
+
113
+ # `sources:` takes source keys, adapter classes, adapter instances, or nil
114
+ # for whatever `config.sources` names.
115
+ #
116
+ # `baseline:` is what a previous run measured -- a Doctor::Report, or a
117
+ # Hash of source to Profile -- for the checks a stored snapshot cannot
118
+ # supply. Everything derived from the entities is recomputed from what is
119
+ # in storage and needs nothing passed here; the warning classes and the
120
+ # free-text coverage exist only during a parse, so a host that wants those
121
+ # compared week to week keeps the last report and hands it back:
122
+ #
123
+ # yesterday = JSON.parse(File.read("doctor.json"))
124
+ # report = ActiveSanction.doctor(baseline: Doctor::Report.from_h(yesterday))
125
+ # File.write("doctor.json", JSON.generate(report.to_h))
126
+ #
127
+ # A supplied profile is used only where it describes the same list version
128
+ # that is in storage; where it does not, storage wins, because a profile
129
+ # from three syncs ago would report drift that has already been reviewed.
130
+ sig do
131
+ params(sources: T.untyped, store: T.untyped, baseline: T.untyped, tolerance: T.untyped,
132
+ logger: T.untyped).void
133
+ end
134
+ def initialize(sources: nil, store: nil, baseline: nil, tolerance: nil, logger: ActiveSanction.config.logger)
135
+ @sources = T.let(resolve(sources), T::Array[T.untyped])
136
+ @keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
137
+ @store = T.let(store || ActiveSanction.storage, T.untyped)
138
+ @recorded = T.let(baselines!(baseline), T::Hash[Symbol, Profile])
139
+ @tolerance = T.let(
140
+ Configuration.doctor_tolerance!(tolerance || ActiveSanction.config.doctor_tolerance), Float
141
+ )
142
+ @logger = T.let(logger, T.untyped)
143
+ end
144
+
145
+ # Runs the diagnosis and returns the Report. Never raises for a source that
146
+ # could not be read -- that is what an `error` finding is for.
147
+ #
148
+ # The optional block is the progress hook: it is called with each Diagnosis
149
+ # as that source finishes. Sources are diagnosed one at a time, because
150
+ # this is a job nobody is waiting on and downloading four government lists
151
+ # at once to save four minutes of it is not a trade worth making.
152
+ sig { params(block: T.nilable(T.proc.params(diagnosis: Diagnosis).void)).returns(Report) }
153
+ def call(&block)
154
+ started_at = Time.now.utc
155
+ began = monotonic
156
+ log(:info, "diagnosing #{keys.size} source(s): #{keys.join(", ")}")
157
+ diagnoses = keys.each_with_index.map do |key, at|
158
+ diagnose(key, sources.fetch(at)).tap do |diagnosis|
159
+ log_diagnosis(diagnosis)
160
+ block&.call(diagnosis)
161
+ end
162
+ end
163
+ report = Report.new(diagnoses: diagnoses, started_at: started_at, duration: elapsed(began))
164
+ log(report.ok? ? :info : :warn, "diagnosed #{report.summary}")
165
+ report
166
+ end
167
+
168
+ sig { returns(String) }
169
+ def inspect = "#<#{self.class} #{keys.join(", ")} tolerance=#{tolerance}>"
170
+
171
+ private
172
+
173
+ # One source, start to finish, inside its own rescue. Nothing in here may
174
+ # raise past this method, and nothing in here may write anything.
175
+ sig { params(key: Symbol, source: T.untyped).returns(Diagnosis) }
176
+ def diagnose(key, source)
177
+ started = monotonic
178
+ baseline = T.let(nil, T.nilable(Profile))
179
+ begin
180
+ adapter = isolate(source)
181
+ baseline, findings = baseline_for(key)
182
+ observed = measure(key, adapter)
183
+ findings += Checkup.new(source: key, observed: observed, baseline: baseline, floors: floors(adapter),
184
+ tolerance: tolerance).findings
185
+ Diagnosis.new(source: key, status: :checked, findings: findings, profile: observed,
186
+ baseline: baseline, duration: elapsed(started))
187
+ rescue StandardError => e
188
+ failure(key, stamp(key, e), baseline, started)
189
+ end
190
+ end
191
+
192
+ # Fetch, parse, measure. `force: true` because the diagnosis is of the
193
+ # bytes the publisher is serving now, and a 304 would have this reporting
194
+ # on a parse that did not happen.
195
+ sig { params(key: Symbol, adapter: T.untyped).returns(Profile) }
196
+ def measure(key, adapter)
197
+ payloads = adapter.retrieve(force: true)
198
+ raise Sources::MissingPayload, "#{key} answered nothing to an unconditional request" if payloads.nil?
199
+
200
+ snapshot = adapter.snapshot(payloads)
201
+ Profile.measure(snapshot, adapter: adapter, columns: tallies(key, adapter, payloads))
202
+ end
203
+
204
+ # The positional-column assertions, for an adapter that declares any. A
205
+ # source is still diagnosed when they cannot be run: the rest of the
206
+ # checkup is worth having, and the reason they could not be run is a bug
207
+ # here rather than a fact about the publisher's file.
208
+ sig { params(key: Symbol, adapter: T.untyped, payloads: T.untyped).returns(T::Array[T.untyped]) }
209
+ def tallies(key, adapter, payloads)
210
+ return [] unless adapter.respond_to?(:column_tallies)
211
+
212
+ adapter.column_tallies(payloads)
213
+ rescue StandardError => e
214
+ log(:warn, "#{key} column shapes could not be checked (#{e.class}: #{e.message})")
215
+ []
216
+ end
217
+
218
+ # What this source was last measured to be, and any findings raised by
219
+ # trying to find out. A stored snapshot that no longer hashes to its
220
+ # checksum is an `error` in its own right -- the list being screened
221
+ # against cannot prove what it contains -- and leaves the run with no
222
+ # baseline, which is a different thing from a clean comparison.
223
+ sig { params(key: Symbol).returns([T.nilable(Profile), T::Array[Finding]]) }
224
+ def baseline_for(key)
225
+ stored = Profile.measure(store.fetch_snapshot(key))
226
+ recorded = @recorded[key]
227
+ [recorded && recorded.checksum == stored.checksum ? recorded : stored, []]
228
+ rescue Storage::MissingSnapshot
229
+ [@recorded[key], []]
230
+ rescue StandardError => e
231
+ [nil, [Finding.new(source: key, severity: :error, check: :baseline,
232
+ message: "the stored snapshot could not be read, so nothing was compared: " \
233
+ "#{e.class}: #{e.message}")]]
234
+ end
235
+
236
+ sig do
237
+ params(key: Symbol, error: StandardError, baseline: T.nilable(Profile), started: Float).returns(Diagnosis)
238
+ end
239
+ def failure(key, error, baseline, started)
240
+ finding = Finding.new(source: key, severity: :error, check: :parse,
241
+ message: "could not be read: #{error.class}: #{error.message}")
242
+ Diagnosis.new(source: key, status: :failed, findings: [finding], baseline: baseline,
243
+ duration: elapsed(started), error: error)
244
+ end
245
+
246
+ # An adapter built to leave no trace: no payload cache, and validators that
247
+ # live and die with this run. See the class comment on why sharing them
248
+ # would be unsafe. An instance a caller built themselves is used as it
249
+ # stands, and fetches through whatever it was built with.
250
+ sig { params(source: T.untyped).returns(T.untyped) }
251
+ def isolate(source)
252
+ return source unless source.is_a?(Class)
253
+ return T.unsafe(source).new unless isolatable?(source)
254
+
255
+ T.unsafe(source).new(fetcher: Fetcher.new(store: ValidatorStore::Memory.new, logger: logger),
256
+ cache: nil, logger: logger)
257
+ end
258
+
259
+ # Whether a source class takes the keywords Sources::Base does. A third
260
+ # party's adapter that does not is built plainly rather than not at all.
261
+ sig { params(klass: T.untyped).returns(T::Boolean) }
262
+ def isolatable?(klass)
263
+ parameters = klass.instance_method(:initialize).parameters
264
+ return true if parameters.any? { |(kind, _name)| kind == :keyrest }
265
+
266
+ named = parameters.select { |(kind, _name)| %i[key keyreq].include?(kind) }.map(&:last)
267
+ (%i[fetcher cache logger] - named).empty?
268
+ end
269
+
270
+ # The lower bounds this adapter committed to, for the checks that have no
271
+ # baseline to compare against. See Sources::Definition#floor.
272
+ sig { params(adapter: T.untyped).returns(T::Hash[Symbol, Numeric]) }
273
+ def floors(adapter) = adapter.respond_to?(:floors) ? adapter.floors : {}
274
+
275
+ sig { params(requested: T.untyped).returns(T::Array[T.untyped]) }
276
+ def resolve(requested)
277
+ listed = Array(requested).flatten.compact
278
+ return Sources.enabled if listed.empty?
279
+
280
+ listed.map { |source| adapter!(source) }
281
+ end
282
+
283
+ sig { params(source: T.untyped).returns(T.untyped) }
284
+ def adapter!(source)
285
+ return Sources[source] if source.is_a?(Symbol) || source.is_a?(String)
286
+ return source if source.respond_to?(:key) && source.respond_to?(source.is_a?(Class) ? :new : :retrieve)
287
+
288
+ raise InvalidArgument, "a source must be a registered key, or answer .key and .new, got #{source.inspect}"
289
+ end
290
+
291
+ # Takes a Report, a Hash of source to Profile, or the `#to_h` of either,
292
+ # so that a job which round-tripped last night's report through JSON does
293
+ # not have to rebuild it itself.
294
+ sig { params(value: T.untyped).returns(T::Hash[Symbol, Profile]) }
295
+ def baselines!(value)
296
+ case value
297
+ when nil then {}
298
+ when Report then value.profiles
299
+ when Hash then report?(value) ? Report.from_h(value).profiles : profiles!(value)
300
+ else raise InvalidArgument, "baseline must be a Doctor::Report or a Hash of source => Profile"
301
+ end
302
+ end
303
+
304
+ # A serialized Report rather than a Hash of profiles. String keys as well
305
+ # as symbols, since a report that has been through JSON has string keys all
306
+ # the way down.
307
+ sig { params(value: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
308
+ def report?(value) = value.key?(:diagnoses) || value.key?("diagnoses")
309
+
310
+ sig { params(value: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, Profile]) }
311
+ def profiles!(value)
312
+ value.to_h do |source, profile|
313
+ [source.to_sym, profile.is_a?(Profile) ? profile : Profile.from_h(profile)]
314
+ end
315
+ end
316
+
317
+ # A failure captured for a source names that source, even when it was
318
+ # raised somewhere that could not know. See Error#in_source.
319
+ sig { params(key: Symbol, error: StandardError).returns(StandardError) }
320
+ def stamp(key, error) = error.is_a?(ActiveSanction::Error) ? error.in_source(key) : error
321
+
322
+ sig { returns(Float) }
323
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
324
+
325
+ sig { params(started: Float).returns(Float) }
326
+ def elapsed(started) = (monotonic - started).round(3).to_f
327
+
328
+ sig { params(diagnosis: Diagnosis).void }
329
+ def log_diagnosis(diagnosis)
330
+ level = diagnosis.ok? ? :info : :warn
331
+ log(level, "#{diagnosis.source} #{diagnosis.label}: #{diagnosis.findings.size} finding(s) " \
332
+ "in #{format("%.2f", diagnosis.duration)}s")
333
+ diagnosis.findings.each { |finding| log(finding.at_least?(:warn) ? :warn : :info, finding.to_s) }
334
+ end
335
+
336
+ # Configuration only promises a logger that answers #info, so a finding is
337
+ # logged at warn where the logger has one and at info where it does not --
338
+ # rather than not at all.
339
+ sig { params(level: Symbol, message: String).void }
340
+ def log(level, message)
341
+ return unless logger
342
+
343
+ line = "[active_sanction] #{message}"
344
+ logger.respond_to?(level) ? logger.public_send(level, line) : logger.info(line)
345
+ end
346
+ end
347
+ end