Class: ActiveSanction::Doctor::Diagnosis

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/doctor/diagnosis.rb

Overview

What the doctor found out about one source.

diagnosis.source     # => :ofac_sdn
diagnosis.ok?        # => false
diagnosis.severity   # => :warn
diagnosis.findings   # => [Finding, ...]
diagnosis.profile    # => Profile, what this run measured
diagnosis.compared?  # => true, there was a previous list to compare with

Two statuses, and they are about the diagnosis rather than about the list:

:checked  the list was fetched, parsed and measured
:failed   it could not be, and the exception is the finding

A source that fails to fetch is not a source in good health, but nor is it one this can say anything about -- which is why the failure is recorded as an error finding and the profile is nil, rather than a profile of nothing being compared against the last good one and reported as every field collapsing at once.

Nothing here is stored. A diagnosis is what the doctor returns, and keeping it -- to compare a warning class against next week, to graph a fill rate -- is the host application's decision, which is why #to_h serializes the profile along with the findings.

Instances are frozen on construction and compare by value.

Constant Summary collapse

STATUSES =

Whether the doctor got far enough to have an opinion. failed means the list could not be read at all, which is a different report from one that read it and found something wrong with it.

T.let(%i[checked failed].freeze, T::Array[Symbol])

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, status:, findings: [], profile: nil, baseline: nil, duration: 0.0, error: nil) ⇒ void

Parameters:

  • source (T.untyped)
  • status (T.untyped)
  • findings (T.untyped) (defaults to: [])
  • profile (T.untyped) (defaults to: nil)
  • baseline (T.untyped) (defaults to: nil)
  • duration (T.untyped) (defaults to: 0.0)
  • error (T.untyped) (defaults to: nil)


92
93
94
95
96
97
98
99
100
101
102
# File 'lib/active_sanction/doctor/diagnosis.rb', line 92

def initialize(source:, status:, findings: [], profile: nil, baseline: nil, duration: 0.0, error: nil)
  @source = T.let(symbol!(:source, source), Symbol)
  @status = T.let(status!(status), Symbol)
  @findings = T.let(findings!(findings), T::Array[Finding])
  @profile = T.let(profile!(profile), T.nilable(Profile))
  @baseline = T.let(profile!(baseline), T.nilable(Profile))
  @duration = T.let(duration.to_f, Float)
  @exception = T.let(error.is_a?(Exception) ? error : nil, T.nilable(Exception))
  @failure = T.let(failure!(error), T.nilable(T::Hash[Symbol, String]))
  freeze
end

Instance Attribute Details

#baseline ⇒ Profile? (readonly)

What it was measured against: the profile of the snapshot in storage, or the one a caller kept from the last run. nil when there was nothing to compare with, which is what makes the difference between "this is the first look" and "nothing changed".

Returns:



68
69
70
# File 'lib/active_sanction/doctor/diagnosis.rb', line 68

def baseline
  @baseline
end

#duration ⇒ Float (readonly)

Returns:

  • (Float)


71
72
73
# File 'lib/active_sanction/doctor/diagnosis.rb', line 71

def duration
  @duration
end

#exception ⇒ Exception? (readonly)

The exception behind a :failed diagnosis, for a caller that wants the backtrace. nil after a round-trip through #to_h, exactly as Sync::Result has it.

Returns:

  • (Exception, nil)


77
78
79
# File 'lib/active_sanction/doctor/diagnosis.rb', line 77

def exception
  @exception
end

#findings ⇒ Array<Finding> (readonly)

Returns:



57
58
59
# File 'lib/active_sanction/doctor/diagnosis.rb', line 57

def findings
  @findings
end

#profile ⇒ Profile? (readonly)

What this run measured, or nil for a source that could not be read.

Returns:



61
62
63
# File 'lib/active_sanction/doctor/diagnosis.rb', line 61

def profile
  @profile
end

#source ⇒ Symbol (readonly)

Returns:

  • (Symbol)


51
52
53
# File 'lib/active_sanction/doctor/diagnosis.rb', line 51

def source
  @source
end

#status ⇒ Symbol (readonly)

Returns:

  • (Symbol)


54
55
56
# File 'lib/active_sanction/doctor/diagnosis.rb', line 54

def status
  @status
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



80
81
82
83
84
85
86
# File 'lib/active_sanction/doctor/diagnosis.rb', line 80

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Doctor::Diagnosis attribute(s): #{unknown.join(", ")}" if unknown.any?

  T.unsafe(self).new(**attributes)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


185
186
187
188
189
# File 'lib/active_sanction/doctor/diagnosis.rb', line 185

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#checked? ⇒ Boolean

Returns:

  • (Boolean)


105
# File 'lib/active_sanction/doctor/diagnosis.rb', line 105

def checked? = status == :checked

#compared? ⇒ Boolean

Whether there was a previous list to measure this one against. A run with no baseline is held to the adapter's committed floors instead, and says so rather than treating a first look as a regression.

Returns:

  • (Boolean)


114
# File 'lib/active_sanction/doctor/diagnosis.rb', line 114

def compared? = !baseline.nil?

#error ⇒ String?

The failure on one line, for a log or a table. nil when nothing failed.

Returns:

  • (String, nil)


145
146
147
148
149
150
151
152
# File 'lib/active_sanction/doctor/diagnosis.rb', line 145

def error
  return nil unless error_class

  message = error_message
  return error_class if message.nil? || message.empty? || message == error_class

  "#{error_class}: #{message}"
end

#error_class ⇒ String?

Returns:

  • (String, nil)


138
# File 'lib/active_sanction/doctor/diagnosis.rb', line 138

def error_class = @failure&.fetch(:class)

#error_message ⇒ String?

Returns:

  • (String, nil)


141
# File 'lib/active_sanction/doctor/diagnosis.rb', line 141

def error_message = @failure&.fetch(:message)

#errors ⇒ Array<Finding>

Returns:



129
# File 'lib/active_sanction/doctor/diagnosis.rb', line 129

def errors = findings.select(&:error?)

#failed? ⇒ Boolean

Returns:

  • (Boolean)


108
# File 'lib/active_sanction/doctor/diagnosis.rb', line 108

def failed? = status == :failed

#hash ⇒ Integer

Returns:

  • (Integer)


193
# File 'lib/active_sanction/doctor/diagnosis.rb', line 193

def hash = [self.class, to_h].hash

#headline(width = 0) ⇒ String

The heading a source gets in the report, followed by its findings:

ofac_sdn         WARN  3 findings
un_consolidated  OK

Parameters:

  • width (Integer) (defaults to: 0)

Returns:

  • (String)


168
169
170
171
172
173
# File 'lib/active_sanction/doctor/diagnosis.rb', line 168

def headline(width = 0)
  heading = "#{source.to_s.ljust(width)}  #{label}"
  return heading if findings.empty?

  "#{heading}  #{findings.size} finding#{"s" unless findings.size == 1}"
end

#infos ⇒ Array<Finding>

Returns:



135
# File 'lib/active_sanction/doctor/diagnosis.rb', line 135

def infos = findings.select(&:info?)

#inspect ⇒ String

Returns:

  • (String)


196
# File 'lib/active_sanction/doctor/diagnosis.rb', line 196

def inspect = "#<#{self.class} #{source} #{label} #{findings.size} finding(s)>"

#label ⇒ String

OK, INFO, WARN or ERROR -- what an operator's eye goes down the left-hand column looking for.

Returns:

  • (String)


201
# File 'lib/active_sanction/doctor/diagnosis.rb', line 201

def label = (severity || :ok).to_s.upcase

#lines(width = 0) ⇒ Array<String>

This source's block of the report: its heading, then one line per finding. width is how wide the source column is across the whole run, so that the labels line up in a column an eye can run down.

Parameters:

  • width (Integer) (defaults to: 0)

Returns:

  • (Array<String>)


179
# File 'lib/active_sanction/doctor/diagnosis.rb', line 179

def lines(width = 0) = [headline(width)] + findings.map(&:to_line)

#ok? ⇒ Boolean

Nothing above info. The question the CLI's OK answers, and the one a nightly job alerts on.

Returns:

  • (Boolean)


119
# File 'lib/active_sanction/doctor/diagnosis.rb', line 119

def ok? = findings.none? { |finding| finding.at_least?(:warn) }

#record_count ⇒ Integer

Returns:

  • (Integer)


155
# File 'lib/active_sanction/doctor/diagnosis.rb', line 155

def record_count = profile&.record_count || 0

#severity ⇒ Symbol?

The most serious severity found, or nil for a source with nothing to say about it at all.

Returns:

  • (Symbol, nil)


124
125
126
# File 'lib/active_sanction/doctor/diagnosis.rb', line 124

def severity
  Finding::SEVERITIES.reverse.find { |level| findings.any? { |finding| finding.severity == level } }
end

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


158
159
160
161
# File 'lib/active_sanction/doctor/diagnosis.rb', line 158

def to_h
  { source: source, status: status, findings: findings.map(&:to_h), profile: profile&.to_h,
    baseline: baseline&.to_h, duration: duration, error: @failure }
end

#to_s ⇒ String

Returns:

  • (String)


182
# File 'lib/active_sanction/doctor/diagnosis.rb', line 182

def to_s = lines.join("\n")

#warnings ⇒ Array<Finding>

Returns:



132
# File 'lib/active_sanction/doctor/diagnosis.rb', line 132

def warnings = findings.select(&:warn?)