Class: Henitai::Result

Inherits:
Object
  • Object
show all
Includes:
UnparseHelper
Defined in:
lib/henitai/result.rb,
sig/henitai.rbs

Overview

Aggregates the outcome of a complete mutation testing run.

Provides metrics and the serialised Stryker mutation-testing-report-schema JSON payload. The schema version follows stryker-mutator/mutation-testing-elements.

Constant Summary collapse

SCHEMA_VERSION =

Returns:

  • (String)
"1.0"
DEFAULT_THRESHOLDS =

Returns:

  • (Hash[Symbol, Integer])
{ high: 80, low: 60 }.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from UnparseHelper

#fallback_source, #safe_unparse

Constructor Details

#initialize(mutants:, started_at:, finished_at:, thresholds: nil, partial_rerun: false, survivor_stats: nil, session_id: SecureRandom.uuid, git_sha: nil, source_provider: ->(_file) { "" }, authoritative: true, since: nil) ⇒ Result

rubocop:disable Metrics/ParameterLists

Parameters:

  • source_provider (#call) (defaults to: ->(_file) { "" })

    maps a file path to its source string. Injected so the domain object performs no disk IO; the caller (which already knows the file paths) supplies the contents. The default provider returns "" for every file — callers that need real source in the schema (e.g. the runner) inject a provider primed with file content.

  • authoritative (Boolean) (defaults to: true)

    whether this run covers the full configured mutation scope. Authoritative runs fully replace the canonical report; non-authoritative (subject-scoped, --since, --survivors-from) runs are merged into it instead — see CanonicalReportMerger.

  • mutants: (Array[Mutant])
  • started_at: (Time)
  • finished_at: (Time)
  • thresholds: (Hash[Symbol, Integer], nil) (defaults to: nil)
  • partial_rerun: (Boolean) (defaults to: false)
  • survivor_stats: (Hash[Symbol, untyped], nil) (defaults to: nil)
  • session_id: (String) (defaults to: SecureRandom.uuid)
  • git_sha: (String, nil) (defaults to: nil)
  • source_provider: (^(String) -> String) (defaults to: ->(_file) { "" })
  • authoritative: (Boolean) (defaults to: true)
  • since: (String, nil) (defaults to: nil)


32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/henitai/result.rb', line 32

def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
               partial_rerun: false, survivor_stats: nil,
               session_id: SecureRandom.uuid, git_sha: nil,
               source_provider: ->(_file) { "" }, authoritative: true,
               since: nil)
  @mutants         = mutants
  @started_at      = started_at
  @finished_at     = finished_at
  @thresholds      = DEFAULT_THRESHOLDS.merge(thresholds || {})
  @partial_rerun   = partial_rerun
  @survivor_stats  = survivor_stats
  @session_id      = session_id
  @git_sha         = git_sha
  @source_provider = source_provider
  @authoritative   = authoritative
  @since           = since
end

Instance Attribute Details

#finished_atTime (readonly)

Returns the value of attribute finished_at.

Returns:

  • (Time)


18
19
20
# File 'lib/henitai/result.rb', line 18

def finished_at
  @finished_at
end

#git_shaString? (readonly)

Returns the value of attribute git_sha.

Returns:

  • (String, nil)


18
19
20
# File 'lib/henitai/result.rb', line 18

def git_sha
  @git_sha
end

#mutantsArray[Mutant] (readonly)

Returns the value of attribute mutants.

Returns:



18
19
20
# File 'lib/henitai/result.rb', line 18

def mutants
  @mutants
end

#session_idString (readonly)

Returns the value of attribute session_id.

Returns:

  • (String)


18
19
20
# File 'lib/henitai/result.rb', line 18

def session_id
  @session_id
end

#sinceString? (readonly)

Returns the value of attribute since.

Returns:

  • (String, nil)


18
19
20
# File 'lib/henitai/result.rb', line 18

def since
  @since
end

#started_atTime (readonly)

Returns the value of attribute started_at.

Returns:

  • (Time)


18
19
20
# File 'lib/henitai/result.rb', line 18

def started_at
  @started_at
end

#survivor_statsHash[Symbol, untyped]? (readonly)

Returns the value of attribute survivor_stats.

Returns:

  • (Hash[Symbol, untyped], nil)


18
19
20
# File 'lib/henitai/result.rb', line 18

def survivor_stats
  @survivor_stats
end

#thresholdsHash[Symbol, Integer] (readonly)

Returns the value of attribute thresholds.

Returns:

  • (Hash[Symbol, Integer])


18
19
20
# File 'lib/henitai/result.rb', line 18

def thresholds
  @thresholds
end

Instance Method Details

#authoritative?Boolean

Returns:

  • (Boolean)


52
# File 'lib/henitai/result.rb', line 52

def authoritative? = @authoritative

#base_schemaHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


160
161
162
163
164
165
166
167
# File 'lib/henitai/result.rb', line 160

def base_schema
  { # : Hash[Symbol, untyped]
    schemaVersion: SCHEMA_VERSION,
    sessionId: @session_id,
    thresholds: thresholds,
    files: build_files_section
  }
end

#build_files_sectionHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


175
176
177
178
179
180
181
182
183
# File 'lib/henitai/result.rb', line 175

def build_files_section
  mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
    {
      language: "ruby",
      source: @source_provider.call(file_mutants.first.location[:file]),
      mutants: file_mutants.map { |m| mutant_to_schema(m) }
    }
  end
end

#coverage_schema(mutant) ⇒ Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


201
202
203
204
205
206
207
208
209
# File 'lib/henitai/result.rb', line 201

def coverage_schema(mutant)
  covered_by = Array(mutant.covered_by).compact
  return {} if covered_by.empty?

  {
    coveredBy: covered_by,
    testsCompleted: mutant.tests_completed || covered_by.size
  }
end

#detectedInteger

Detected = killed + timeout + runtime_error (alle Zustände die einen Fehler beweisen)

Returns:

  • (Integer)


65
66
67
# File 'lib/henitai/result.rb', line 65

def detected
  detected_in(mutants)
end

#detected_in(list) ⇒ Integer

Parameters:

Returns:

  • (Integer)


142
143
144
# File 'lib/henitai/result.rb', line 142

def detected_in(list)
  list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
end

#durationFloat

Returns duration in seconds.

Returns:

  • (Float)

    duration in seconds



123
124
125
# File 'lib/henitai/result.rb', line 123

def duration
  finished_at - started_at
end

#duration_for(mutant) ⇒ Integer?

Parameters:

Returns:

  • (Integer, nil)


232
233
234
# File 'lib/henitai/result.rb', line 232

def duration_for(mutant)
  mutant.duration&.then { |d| (d * 1000).round }
end

#equivalence_uncertaintyString?

Returns:

  • (String, nil)


258
259
260
261
262
# File 'lib/henitai/result.rb', line 258

def equivalence_uncertainty
  return nil if mutation_score.nil?

  "~10-15% of live mutants"
end

#equivalentInteger

Returns number of confirmed equivalent mutants (excluded from MS).

Returns:

  • (Integer)

    number of confirmed equivalent mutants (excluded from MS)



61
# File 'lib/henitai/result.rb', line 61

def equivalent = mutants.count(&:equivalent?)

#executed_scoring_summaryHash?

Scores over only the mutants actually executed this run — verdicts reused from the history store (--incremental) excluded. nil when nothing was reused: the combined scores already tell the whole story, and reporters use nil to suppress the extra line.

Returns:

  • (Hash, nil)

    { mutation_score:, mutation_score_indicator: }



100
101
102
103
104
105
106
107
108
# File 'lib/henitai/result.rb', line 100

def executed_scoring_summary
  executed = mutants.reject { |m| m.respond_to?(:from_cache?) && m.from_cache? }
  return nil if executed.size == mutants.size

  {
    mutation_score: mutation_score_for(executed),
    mutation_score_indicator: mutation_score_indicator_for(executed)
  }
end

#from_cache_for(mutant) ⇒ Boolean?

Vendored extension field (like stableId): marks verdicts reused from the history store so reports stay honest about what actually executed.

Parameters:

Returns:

  • (Boolean, nil)


246
247
248
249
250
# File 'lib/henitai/result.rb', line 246

def from_cache_for(mutant)
  return nil unless mutant.respond_to?(:from_cache?)

  mutant.from_cache? || nil
end

#killedInteger

Returns number of killed mutants.

Returns:

  • (Integer)

    number of killed mutants



55
# File 'lib/henitai/result.rb', line 55

def killed   = mutants.count(&:killed?)

#legacy_stable_id_for(mutant) ⇒ String?

Parameters:

Returns:

  • (String, nil)


252
253
254
255
256
# File 'lib/henitai/result.rb', line 252

def legacy_stable_id_for(mutant)
  return if mutant.respond_to?(:precomputed_stable_id) && mutant.precomputed_stable_id

  MutantIdentity.legacy_stable_id(mutant)
end

#line_column(mutant, prefix) ⇒ Hash[Symbol, Integer]

Parameters:

Returns:

  • (Hash[Symbol, Integer])


224
225
226
227
228
229
230
# File 'lib/henitai/result.rb', line 224

def line_column(mutant, prefix)
  # Stryker schema columns are 1-based; Parser locations are 0-based.
  {
    line: mutant.location.fetch(:"#{prefix}_line"),
    column: mutant.location.fetch(:"#{prefix}_col") + 1
  }
end

#location_for(mutant) ⇒ Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


217
218
219
220
221
222
# File 'lib/henitai/result.rb', line 217

def location_for(mutant)
  {
    start: line_column(mutant, :start),
    end: line_column(mutant, :end)
  }
end

#mutant_to_schema(mutant) ⇒ Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/henitai/result.rb', line 185

def mutant_to_schema(mutant)
  {
    id: mutant.id,
    stableId: mutant.stable_id,
    legacyStableId: legacy_stable_id_for(mutant),
    mutatorName: mutant.operator,
    replacement: replacement_for(mutant),
    location: location_for(mutant),
    status: stryker_status(mutant.status),
    statusReason: status_reason_for(mutant),
    fromCache: from_cache_for(mutant),
    description: mutant.description,
    duration: duration_for(mutant)
  }.compact.merge(coverage_schema(mutant))
end

#mutation_scoreFloat?

Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:

MS = detected / (total − ignored − no_coverage − compile_error − equivalent)

Confirmed equivalent mutants werden aus BEIDEN Seiten der Gleichung entfernt: Sie sind weder im Zähler (nicht detektierbar) noch im Nenner (nicht testbar). Das ist der entscheidende Unterschied zum MSI.

Returns:

  • (Float, nil)

    0.0–100.0, nil wenn kein valider Mutant vorhanden



78
79
80
# File 'lib/henitai/result.rb', line 78

def mutation_score
  mutation_score_for(mutants)
end

#mutation_score_for(list) ⇒ Float?

Parameters:

Returns:

  • (Float, nil)


146
147
148
149
150
151
152
# File 'lib/henitai/result.rb', line 146

def mutation_score_for(list)
  excluded = %i[ignored no_coverage compile_error equivalent]
  valid = list.reject { |m| excluded.include?(m.status) }
  return nil if valid.empty?

  ((detected_in(list).to_f / valid.count) * 100.0).round(2).to_f
end

#mutation_score_indicatorFloat?

Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:

MSI = killed / all_mutants

MSI ist immer ≤ MS. Der Unterschied kommuniziert die Äquivalenz-Unsicherheit. Beide Werte MÜSSEN im Report zusammen ausgewiesen werden (Anti-Pattern: nur MS).

Returns:

  • (Float, nil)


90
91
92
# File 'lib/henitai/result.rb', line 90

def mutation_score_indicator
  mutation_score_indicator_for(mutants)
end

#mutation_score_indicator_for(list) ⇒ Float?

Parameters:

Returns:

  • (Float, nil)


154
155
156
157
158
# File 'lib/henitai/result.rb', line 154

def mutation_score_indicator_for(list)
  return nil if list.empty?

  ((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
end

#partial_rerun?Boolean

rubocop:enable Metrics/ParameterLists

Returns:

  • (Boolean)


51
# File 'lib/henitai/result.rb', line 51

def partial_rerun? = @partial_rerun

#replacement_for(mutant) ⇒ String

Parameters:

Returns:

  • (String)


211
212
213
214
215
# File 'lib/henitai/result.rb', line 211

def replacement_for(mutant)
  return mutant.mutated_source if mutant.respond_to?(:mutated_source)

  safe_unparse(mutant.mutated_node)
end

#scoring_summaryHash[Symbol, untyped]

Compact public summary for reporters. The uncertainty note is intentionally qualitative: equivalent mutants are a known gray area, so the terminal report should communicate that uncertainty instead of pretending to be precise.

Returns:

  • (Hash[Symbol, untyped])


114
115
116
117
118
119
120
# File 'lib/henitai/result.rb', line 114

def scoring_summary
  {
    mutation_score: mutation_score,
    mutation_score_indicator: mutation_score_indicator,
    equivalence_uncertainty: equivalence_uncertainty
  }
end

#status_reason_for(mutant) ⇒ String?

Serialized as the schema's statusReason so directive reasons show up next to the Ignored status in the HTML report.

Parameters:

Returns:

  • (String, nil)


238
239
240
241
242
# File 'lib/henitai/result.rb', line 238

def status_reason_for(mutant)
  return nil unless mutant.respond_to?(:ignore_reason)

  mutant.ignore_reason
end

#stryker_status(status) ⇒ String

Parameters:

  • (Symbol)

Returns:

  • (String)


264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/henitai/result.rb', line 264

def stryker_status(status)
  # :equivalent wird als "Ignored" serialisiert — das Stryker-Schema kennt keinen
  # Equivalent-Status. Die interne Unterscheidung (für MS vs. MSI) bleibt im Result-Objekt.
  {
    killed: "Killed",
    survived: "Survived",
    timeout: "Timeout",
    no_coverage: "NoCoverage",
    ignored: "Ignored",
    equivalent: "Ignored",
    compile_error: "CompileError",
    runtime_error: "RuntimeError",
    pending: "Pending"
  }.fetch(status, "Pending")
end

#survivedInteger

Returns number of survived mutants.

Returns:

  • (Integer)

    number of survived mutants



58
# File 'lib/henitai/result.rb', line 58

def survived = mutants.count(&:survived?)

#to_stryker_schemaHash

Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).

Returns:

  • (Hash)


129
130
131
132
133
134
135
136
137
138
# File 'lib/henitai/result.rb', line 129

def to_stryker_schema
  schema = base_schema
  sha = @git_sha
  schema[:gitSha] = sha if sha
  return schema unless partial_rerun?

  schema[:partialRerun] = true
  schema[:unmatchedSurvivorIds] = unmatched_survivor_ids
  schema
end

#unmatched_survivor_idsArray[String]

Returns:

  • (Array[String])


169
170
171
172
173
# File 'lib/henitai/result.rb', line 169

def unmatched_survivor_ids
  return survivor_stats.fetch(:unmatched_ids) if survivor_stats

  [] # : Array[String]
end