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) ⇒ 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)


32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 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)
  @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
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

#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)


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

def authoritative? = @authoritative

#base_schemaHash[Symbol, untyped]

Returns:

  • (Hash[Symbol, untyped])


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

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])


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

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])


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

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)


63
64
65
# File 'lib/henitai/result.rb', line 63

def detected
  detected_in(mutants)
end

#detected_in(list) ⇒ Integer

Parameters:

Returns:

  • (Integer)


140
141
142
# File 'lib/henitai/result.rb', line 140

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



121
122
123
# File 'lib/henitai/result.rb', line 121

def duration
  finished_at - started_at
end

#duration_for(mutant) ⇒ Integer?

Parameters:

Returns:

  • (Integer, nil)


230
231
232
# File 'lib/henitai/result.rb', line 230

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

#equivalence_uncertaintyString?

Returns:

  • (String, nil)


256
257
258
259
260
# File 'lib/henitai/result.rb', line 256

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)



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

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: }



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

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)


244
245
246
247
248
# File 'lib/henitai/result.rb', line 244

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



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

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

#legacy_stable_id_for(mutant) ⇒ String?

Parameters:

Returns:

  • (String, nil)


250
251
252
253
254
# File 'lib/henitai/result.rb', line 250

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])


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

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])


215
216
217
218
219
220
# File 'lib/henitai/result.rb', line 215

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])


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

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



76
77
78
# File 'lib/henitai/result.rb', line 76

def mutation_score
  mutation_score_for(mutants)
end

#mutation_score_for(list) ⇒ Float?

Parameters:

Returns:

  • (Float, nil)


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

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)


88
89
90
# File 'lib/henitai/result.rb', line 88

def mutation_score_indicator
  mutation_score_indicator_for(mutants)
end

#mutation_score_indicator_for(list) ⇒ Float?

Parameters:

Returns:

  • (Float, nil)


152
153
154
155
156
# File 'lib/henitai/result.rb', line 152

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)


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

def partial_rerun? = @partial_rerun

#replacement_for(mutant) ⇒ String

Parameters:

Returns:

  • (String)


209
210
211
212
213
# File 'lib/henitai/result.rb', line 209

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])


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

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)


236
237
238
239
240
# File 'lib/henitai/result.rb', line 236

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)


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

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



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

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

#to_stryker_schemaHash

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

Returns:

  • (Hash)


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

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])


167
168
169
170
171
# File 'lib/henitai/result.rb', line 167

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

  [] # : Array[String]
end