sixth_sense 0.1.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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/exe/sixth_sense +7 -0
  5. data/lib/sixth_sense/adapters/minitest.rb +145 -0
  6. data/lib/sixth_sense/adapters/rspec.rb +373 -0
  7. data/lib/sixth_sense/adapters/test_unit.rb +142 -0
  8. data/lib/sixth_sense/analysis_context.rb +35 -0
  9. data/lib/sixth_sense/analysis_runner.rb +141 -0
  10. data/lib/sixth_sense/analyzer.rb +85 -0
  11. data/lib/sixth_sense/analyzers/adequacy_checked_coverage.rb +39 -0
  12. data/lib/sixth_sense/analyzers/adequacy_coverage.rb +63 -0
  13. data/lib/sixth_sense/analyzers/adequacy_mutation.rb +141 -0
  14. data/lib/sixth_sense/analyzers/quality_assertion_density.rb +41 -0
  15. data/lib/sixth_sense/analyzers/quality_flakiness.rb +53 -0
  16. data/lib/sixth_sense/analyzers/quality_test_smells.rb +253 -0
  17. data/lib/sixth_sense/analyzers/redundancy_clone.rb +54 -0
  18. data/lib/sixth_sense/analyzers/redundancy_coverage.rb +39 -0
  19. data/lib/sixth_sense/analyzers/redundancy_mutation.rb +39 -0
  20. data/lib/sixth_sense/analyzers/redundancy_requirement.rb +70 -0
  21. data/lib/sixth_sense/changed_files.rb +45 -0
  22. data/lib/sixth_sense/cli.rb +229 -0
  23. data/lib/sixth_sense/config.rb +91 -0
  24. data/lib/sixth_sense/engines/mutant.rb +258 -0
  25. data/lib/sixth_sense/framework_adapter.rb +55 -0
  26. data/lib/sixth_sense/guardrail/baseline.rb +135 -0
  27. data/lib/sixth_sense/guardrail/evaluator.rb +69 -0
  28. data/lib/sixth_sense/model.rb +264 -0
  29. data/lib/sixth_sense/mutation_cache.rb +93 -0
  30. data/lib/sixth_sense/mutation_engine.rb +52 -0
  31. data/lib/sixth_sense/mutation_matrix_mutant_generator.rb +462 -0
  32. data/lib/sixth_sense/mutation_matrix_producer.rb +308 -0
  33. data/lib/sixth_sense/mutation_score_cache_writer.rb +75 -0
  34. data/lib/sixth_sense/rake_task.rb +16 -0
  35. data/lib/sixth_sense/reporters/console.rb +31 -0
  36. data/lib/sixth_sense/reporters/html.rb +62 -0
  37. data/lib/sixth_sense/reporters/json.rb +18 -0
  38. data/lib/sixth_sense/reporters/markdown.rb +34 -0
  39. data/lib/sixth_sense/reporters/sarif.rb +77 -0
  40. data/lib/sixth_sense/result.rb +86 -0
  41. data/lib/sixth_sense/runners/checked_coverage_estimator.rb +62 -0
  42. data/lib/sixth_sense/runners/checked_coverage_runner.rb +130 -0
  43. data/lib/sixth_sense/runners/checked_coverage_trace.rb +110 -0
  44. data/lib/sixth_sense/runners/coverage_runner.rb +220 -0
  45. data/lib/sixth_sense/runners/coverage_snapshot.rb +64 -0
  46. data/lib/sixth_sense/runners/minitest_checked_coverage_probe.rb +42 -0
  47. data/lib/sixth_sense/runners/minitest_coverage_probe.rb +49 -0
  48. data/lib/sixth_sense/runners/rspec_checked_coverage_probe.rb +26 -0
  49. data/lib/sixth_sense/runners/rspec_coverage_probe.rb +98 -0
  50. data/lib/sixth_sense/runners/test_unit_checked_coverage_probe.rb +43 -0
  51. data/lib/sixth_sense/runners/test_unit_coverage_probe.rb +51 -0
  52. data/lib/sixth_sense/scoring/aggregator.rb +117 -0
  53. data/lib/sixth_sense/source_location.rb +19 -0
  54. data/lib/sixth_sense/version.rb +5 -0
  55. data/lib/sixth_sense.rb +74 -0
  56. metadata +113 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../framework_adapter"
6
+ require_relative "../model"
7
+ require_relative "../source_location"
8
+ require_relative "rspec"
9
+
10
+ module SixthSense
11
+ module Adapters
12
+ class TestUnit < FrameworkAdapter
13
+ ASSERTION_PREFIXES = %w[assert assert_ flunk].freeze
14
+
15
+ def handles?(path)
16
+ return false unless path.end_with?("_test.rb")
17
+ return false unless File.file?(path)
18
+
19
+ File.read(path).match?(/\bTest::Unit::TestCase\b/)
20
+ end
21
+
22
+ def parse(path)
23
+ parsed = Prism.parse_file(path)
24
+ unless parsed.success?
25
+ messages = parsed.errors.map(&:message).join(", ")
26
+ raise Error, "failed to parse #{path}: #{messages}"
27
+ end
28
+
29
+ Model::TestFile.new(
30
+ path: path,
31
+ framework: :test_unit,
32
+ test_cases: Parser.new(path, parsed.value).test_cases,
33
+ sut_candidates: map_to_sut_path(path),
34
+ metadata: {}
35
+ )
36
+ end
37
+
38
+ def run_with_coverage(test_files:, isolation:)
39
+ require_relative "../runners/coverage_runner"
40
+
41
+ Runners::CoverageRunner.new.run(test_files: test_files, isolation: isolation)
42
+ end
43
+
44
+ def mutation_session(test_files:)
45
+ { framework: :test_unit, test_files: test_files.map(&:path), runner: "ruby" }
46
+ end
47
+
48
+ def map_to_sut(test_file)
49
+ map_to_sut_path(test_file.path)
50
+ end
51
+
52
+ private
53
+
54
+ def map_to_sut_path(test_path)
55
+ relative = test_path.tr("\\", "/").sub(%r{\A(?:.*?/)?(?:test|test_unit)/}, "").sub(/_test\.rb\z/, ".rb")
56
+ root = project_root_for(test_path)
57
+ candidates = [File.join("lib", relative), File.join("app", relative), relative]
58
+ if root
59
+ candidates << File.join(root, "lib", relative)
60
+ candidates << File.join(root, "app", relative)
61
+ end
62
+ candidates.uniq.filter_map do |candidate|
63
+ next unless File.file?(candidate)
64
+
65
+ Adapters::RSpec::CodeUnitExtractor.new(candidate).code_unit
66
+ end
67
+ end
68
+
69
+ def project_root_for(test_path)
70
+ normalized = test_path.tr("\\", "/")
71
+ match = %r{\A(.+?)/(?:test|test_unit)/}.match(normalized)
72
+ match&.[](1)
73
+ end
74
+
75
+ class Parser
76
+ def initialize(path, ast)
77
+ @path = path
78
+ @ast = ast
79
+ end
80
+
81
+ def test_cases
82
+ cases = []
83
+ walk(@ast) do |node|
84
+ next unless node.is_a?(Prism::DefNode)
85
+ next unless node.name.to_s.start_with?("test_")
86
+
87
+ cases << test_case_for(node)
88
+ end
89
+ cases
90
+ end
91
+
92
+ private
93
+
94
+ def test_case_for(node)
95
+ body = node.slice.to_s
96
+ Model::TestCase.new(
97
+ id: "#{@path}:#{node.name}",
98
+ description: node.name.to_s.tr("_", " "),
99
+ location: location_for(node),
100
+ assertions: assertions_for(node),
101
+ ast: node,
102
+ body: body,
103
+ metadata: {}
104
+ )
105
+ end
106
+
107
+ def assertions_for(node)
108
+ assertions = []
109
+ walk(node) do |child|
110
+ next unless child.is_a?(Prism::CallNode)
111
+ next unless assertion_call?(child.name)
112
+
113
+ assertions << Model::Assertion.new(
114
+ location: location_for(child),
115
+ matcher: child.name,
116
+ subject_expr: child.arguments&.arguments&.map(&:slice)&.join(", "),
117
+ message: nil
118
+ )
119
+ end
120
+ assertions
121
+ end
122
+
123
+ def assertion_call?(name)
124
+ ASSERTION_PREFIXES.any? { |prefix| name.to_s.start_with?(prefix) }
125
+ end
126
+
127
+ def walk(node, &block)
128
+ return unless node
129
+
130
+ yield node
131
+ node.each_child_node { |child| walk(child, &block) }
132
+ end
133
+
134
+ def location_for(node)
135
+ SourceLocation.new(path: @path, line: node.location.start_line, column: node.location.start_column + 1)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ SixthSense::FrameworkAdapter.register(:test_unit, SixthSense::Adapters::TestUnit)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ class AnalysisContext
5
+ attr_reader :config, :coverage_maps, :checked_coverage_maps, :kill_matrices, :warnings
6
+
7
+ def initialize(config: {}, coverage_maps: {}, checked_coverage_maps: {}, kill_matrices: {}, warnings: [])
8
+ @config = config
9
+ @coverage_maps = coverage_maps
10
+ @checked_coverage_maps = checked_coverage_maps
11
+ @kill_matrices = kill_matrices
12
+ @warnings = warnings
13
+ end
14
+
15
+ def coverage_map_for(test_file)
16
+ coverage_maps[test_file.path]
17
+ end
18
+
19
+ def checked_coverage_map_for(test_file)
20
+ checked_coverage_maps[test_file.path]
21
+ end
22
+
23
+ def kill_matrix_for(test_file)
24
+ kill_matrices[test_file.path]
25
+ end
26
+
27
+ def config_fetch(*keys, default: nil)
28
+ keys.reduce(config) do |current, key|
29
+ break default unless current.respond_to?(:fetch)
30
+
31
+ current.fetch(key.to_s) { current.fetch(key.to_sym, default) }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "analysis_context"
4
+ require_relative "config"
5
+ require_relative "framework_adapter"
6
+ require_relative "model"
7
+ require_relative "mutation_cache"
8
+ require_relative "runners/checked_coverage_estimator"
9
+ require_relative "runners/checked_coverage_runner"
10
+ require_relative "runners/coverage_runner"
11
+ require_relative "scoring/aggregator"
12
+
13
+ module SixthSense
14
+ class AnalysisRunner
15
+ attr_reader :config
16
+
17
+ def initialize(config: Config.load)
18
+ @config = config
19
+ end
20
+
21
+ def analyze(paths:, level:)
22
+ warnings = []
23
+ test_files = discover(paths).filter_map do |path|
24
+ adapter_class = FrameworkAdapter.for_path(path)
25
+ unless adapter_class
26
+ warnings << "No framework adapter accepted #{path}."
27
+ next
28
+ end
29
+
30
+ apply_configured_mapping(adapter_class.new.parse(path))
31
+ rescue Error => error
32
+ warnings << "#{path}: #{error.class}: #{error.message}"
33
+ nil
34
+ end
35
+
36
+ coverage_maps = collect_coverage(test_files, level, warnings)
37
+ checked_maps = collect_checked_coverage(test_files, coverage_maps, level, warnings)
38
+ kill_matrices = collect_mutation(test_files, level, warnings)
39
+ context = AnalysisContext.new(
40
+ config: config.data,
41
+ coverage_maps: coverage_maps,
42
+ checked_coverage_maps: checked_maps,
43
+ kill_matrices: kill_matrices,
44
+ warnings: warnings
45
+ )
46
+ aggregator = Scoring::Aggregator.new(level: level)
47
+ test_files.map { |test_file| aggregator.analyze(test_file, context) }
48
+ end
49
+
50
+ def discover(paths)
51
+ selected = paths.empty? ? ["spec"] : paths
52
+ selected.flat_map { |path| expand_path(path) }.uniq.sort
53
+ end
54
+
55
+ private
56
+
57
+ def apply_configured_mapping(test_file)
58
+ mapping = config.fetch(:mapping, test_file.path, default: nil)
59
+ return test_file unless mapping
60
+
61
+ explicit_paths = Array(mapping["paths"] || mapping[:paths])
62
+ explicit_units = explicit_paths.filter_map do |path|
63
+ next unless File.file?(path)
64
+
65
+ Adapters::RSpec::CodeUnitExtractor.new(path).code_unit
66
+ end
67
+ test_file.sut_candidates = explicit_units unless explicit_units.empty?
68
+ test_file.metadata ||= {}
69
+ test_file.metadata[:mutation_subjects] = Array(mapping["subjects"] || mapping[:subjects])
70
+ test_file
71
+ end
72
+
73
+ def collect_coverage(test_files, level, warnings)
74
+ return {} if level < 1 || test_files.empty?
75
+ return {} unless config.fetch(:coverage, :enabled, default: true)
76
+
77
+ Runners::CoverageRunner.new(config: config.fetch(:coverage, default: {})).run(test_files: test_files)
78
+ rescue Error, NotImplementedError => error
79
+ warnings << "coverage disabled: #{error.message}"
80
+ {}
81
+ end
82
+
83
+ def collect_checked_coverage(test_files, coverage_maps, level, warnings)
84
+ return {} if level < 1 || test_files.empty?
85
+
86
+ traced = Runners::CheckedCoverageRunner.new(config: config.fetch(:coverage, default: {})).run(test_files: test_files)
87
+ estimator = Runners::CheckedCoverageEstimator.new
88
+ test_files.each_with_object({}) do |test_file, maps|
89
+ coverage_map = coverage_maps[test_file.path]
90
+ trace_map = traced[test_file.path]
91
+ estimated = coverage_map ? estimator.estimate(test_file, coverage_map) : nil
92
+ maps[test_file.path] = merge_checked_coverage(trace_map, estimated)
93
+ end.reject { |_path, map| map.checked_ratio.nil? }
94
+ rescue Error, NotImplementedError => error
95
+ warnings << "checked coverage trace disabled: #{error.message}"
96
+ estimator = Runners::CheckedCoverageEstimator.new
97
+ test_files.each_with_object({}) do |test_file, maps|
98
+ coverage_map = coverage_maps[test_file.path]
99
+ maps[test_file.path] = estimator.estimate(test_file, coverage_map) if coverage_map
100
+ end
101
+ end
102
+
103
+ def merge_checked_coverage(trace_map, estimated)
104
+ return estimated unless trace_map
105
+ return trace_map unless estimated
106
+
107
+ checked = trace_map.per_test_checked_lines.merge(estimated.per_test_checked_lines) do |_test_id, traced_lines, estimated_lines|
108
+ merge_line_hashes(traced_lines, estimated_lines)
109
+ end
110
+ executed = merge_line_hashes(trace_map.executed_lines, estimated.executed_lines)
111
+ Model::CheckedCoverageMap.new(per_test_checked_lines: checked, executed_lines: executed)
112
+ end
113
+
114
+ def merge_line_hashes(left, right)
115
+ (left.keys + right.keys).uniq.to_h do |path|
116
+ [path, (Array(left[path]) + Array(right[path])).uniq]
117
+ end
118
+ end
119
+
120
+ def collect_mutation(test_files, level, warnings)
121
+ return {} if level < 2 || test_files.empty?
122
+
123
+ cache = MutationCache.new(root: config.fetch(:mutation, :cache_dir, default: MutationCache::DEFAULT_ROOT))
124
+ test_files.each_with_object({}) do |test_file, matrices|
125
+ matrix = cache.load(test_file)
126
+ if matrix
127
+ matrices[test_file.path] = matrix
128
+ else
129
+ warnings << "mutation cache missing for #{test_file.path}; run a matrix producer and write #{cache.path_for(test_file.path)}"
130
+ end
131
+ end
132
+ end
133
+
134
+ def expand_path(path)
135
+ return [path] if File.file?(path)
136
+ return Dir[File.join(path, "**", "*_spec.rb")] + Dir[File.join(path, "**", "*_test.rb")] if File.directory?(path)
137
+
138
+ Dir[path]
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module SixthSense
6
+ AnalyzerResult = Struct.new(:analyzer_id, :axis, :score, :confidence, :findings, :measured, keyword_init: true) do
7
+ def measured?
8
+ measured != false && !score.nil?
9
+ end
10
+ end
11
+
12
+ class Analyzer
13
+ class << self
14
+ def inherited(subclass)
15
+ super
16
+ SixthSense.register_analyzer(subclass)
17
+ end
18
+
19
+ def analyzer_id(id = nil)
20
+ return @analyzer_id unless id
21
+
22
+ @analyzer_id = id
23
+ end
24
+
25
+ def axis(value = nil)
26
+ return @axis unless value
27
+
28
+ @axis = value
29
+ end
30
+
31
+ def level(value = nil)
32
+ return @level unless value
33
+
34
+ @level = value
35
+ end
36
+
37
+ def reference(authors:, title:, venue:, year:, doi: nil)
38
+ references << Result::Reference.new(
39
+ authors: authors,
40
+ title: title,
41
+ venue: venue,
42
+ year: year,
43
+ doi: doi
44
+ )
45
+ end
46
+
47
+ def references
48
+ @references ||= []
49
+ end
50
+ end
51
+
52
+ def analyze(_test_file, _context)
53
+ raise NotImplementedError
54
+ end
55
+
56
+ private
57
+
58
+ def result(score:, findings:, confidence:, measured: true)
59
+ AnalyzerResult.new(
60
+ analyzer_id: self.class.analyzer_id,
61
+ axis: self.class.axis,
62
+ score: score,
63
+ confidence: confidence,
64
+ findings: findings,
65
+ measured: measured
66
+ )
67
+ end
68
+
69
+ def unmeasured(confidence: :low, findings: [])
70
+ result(score: nil, findings: findings, confidence: confidence, measured: false)
71
+ end
72
+
73
+ def finding(rule_id:, severity:, location:, message:, suggestion: nil)
74
+ Result::Finding.new(
75
+ analyzer_id: self.class.analyzer_id,
76
+ rule_id: rule_id,
77
+ severity: severity,
78
+ location: location,
79
+ message: message,
80
+ references: self.class.references,
81
+ suggestion: suggestion
82
+ )
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+ require_relative "../source_location"
5
+
6
+ module SixthSense
7
+ module Analyzers
8
+ class AdequacyCheckedCoverage < Analyzer
9
+ analyzer_id "adequacy/checked_coverage"
10
+ axis :adequacy
11
+ level 1
12
+
13
+ reference authors: "Schuler, D.; Zeller, A.",
14
+ title: "Assessing Oracle Quality with Checked Coverage",
15
+ venue: "ICST",
16
+ year: 2011,
17
+ doi: "10.1109/ICST.2011.32"
18
+
19
+ # Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32)
20
+ # defines checked coverage as executed code associated with an oracle.
21
+ def analyze(test_file, context)
22
+ checked = context.checked_coverage_map_for(test_file)
23
+ return unmeasured(confidence: :low) unless checked&.checked_ratio
24
+
25
+ findings = checked.unchecked_lines.first(10).map do |path, line|
26
+ finding(
27
+ rule_id: "unchecked_executed_line",
28
+ severity: :info,
29
+ location: SourceLocation.new(path: path, line: line, column: 1),
30
+ message: "Executed line was not associated with an assertion subject.",
31
+ suggestion: "Add an assertion that observes this behavior if it is semantically important."
32
+ )
33
+ end
34
+
35
+ result(score: (checked.checked_ratio * 100.0).round(2), findings: findings, confidence: :medium)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class AdequacyCoverage < Analyzer
8
+ analyzer_id "adequacy/coverage"
9
+ axis :adequacy
10
+ level 1
11
+
12
+ reference authors: "Zhu, H.; Hall, P. A. V.; May, J. H. R.",
13
+ title: "Software Unit Test Coverage and Adequacy",
14
+ venue: "ACM Computing Surveys",
15
+ year: 1997,
16
+ doi: "10.1145/267580.267590"
17
+ reference authors: "Inozemtseva, L.; Holmes, R.",
18
+ title: "Coverage Is Not Strongly Correlated with Test Suite Effectiveness",
19
+ venue: "ICSE",
20
+ year: 2014,
21
+ doi: "10.1145/2568225.2568271"
22
+
23
+ # Paper basis: Zhu/Hall/May 1997 (doi:10.1145/267580.267590)
24
+ # surveys coverage adequacy criteria; branch coverage is preferred when present.
25
+ def analyze(test_file, context)
26
+ coverage = context.coverage_map_for(test_file)
27
+ return unmeasured(confidence: :low) unless coverage
28
+
29
+ branch_ratio = coverage.branch_coverage_ratio
30
+ line_ratio = coverage.line_coverage_ratio
31
+ ratio = branch_ratio || line_ratio
32
+ return unmeasured(confidence: :low) unless ratio
33
+
34
+ score = (score_ratio(ratio, context) * 100.0).round(2)
35
+ confidence = branch_ratio ? :medium : :low
36
+ findings = []
37
+ if ratio < 0.8
38
+ findings << finding(
39
+ rule_id: "low_coverage",
40
+ severity: :warning,
41
+ location: test_file.test_cases.first&.location,
42
+ message: "Coverage ratio is #{(ratio * 100).round(1)}%.",
43
+ suggestion: "Add tests for unexecuted branches before relying on adequacy scores."
44
+ )
45
+ end
46
+
47
+ result(score: score, findings: findings, confidence: confidence)
48
+ end
49
+
50
+ private
51
+
52
+ # Paper basis: Inozemtseva/Holmes 2014 (doi:10.1145/2568225.2568271)
53
+ # motivates calibrated mode; raw mode keeps the paper-exact coverage ratio.
54
+ def score_ratio(ratio, context)
55
+ transform = context.config_fetch(:adequacy, :coverage_transform, default: "raw").to_s
56
+ return ratio unless transform == "calibrated"
57
+
58
+ gamma = context.config_fetch(:adequacy, :coverage_gamma, default: 0.5).to_f
59
+ 1.0 - ((1.0 - ratio)**gamma)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class AdequacyMutation < Analyzer
8
+ analyzer_id "adequacy/mutation"
9
+ axis :adequacy
10
+ level 2
11
+
12
+ reference authors: "Jia, Y.; Harman, M.",
13
+ title: "An Analysis and Survey of the Development of Mutation Testing",
14
+ venue: "IEEE Transactions on Software Engineering",
15
+ year: 2011,
16
+ doi: "10.1109/TSE.2010.62"
17
+ reference authors: "Just, R. et al.",
18
+ title: "Are Mutants a Valid Substitute for Real Faults in Software Testing?",
19
+ venue: "FSE",
20
+ year: 2014,
21
+ doi: "10.1145/2635868.2635929"
22
+
23
+ # Paper basis: Jia/Harman 2011 (doi:10.1109/TSE.2010.62) defines
24
+ # mutation score; Just et al. 2014 (doi:10.1145/2635868.2635929)
25
+ # supports mutants as a proxy for real fault detection.
26
+ def analyze(test_file, context)
27
+ matrix = context.kill_matrix_for(test_file)
28
+ return no_subjects(test_file) if !matrix && test_file.sut_candidates.empty?
29
+ if matrix && !matrix.mutation_score
30
+ return result(score: nil, measured: false, confidence: :low, findings: operational_findings(matrix, context))
31
+ end
32
+ return unmeasured(confidence: :low) unless matrix&.mutation_score
33
+
34
+ findings = operational_findings(matrix, context)
35
+ findings.concat(matrix.surviving_mutants.map do |mutant|
36
+ finding(
37
+ rule_id: mutant.status.to_s == "timeout" ? "mutation_timeout" : "surviving_mutant",
38
+ severity: :warning,
39
+ location: mutant.location,
40
+ message: mutant_message(mutant),
41
+ suggestion: mutant_suggestion(mutant)
42
+ )
43
+ end)
44
+
45
+ result(score: (matrix.mutation_score * 100.0).round(2), findings: findings, confidence: confidence_for(matrix))
46
+ end
47
+
48
+ private
49
+
50
+ def no_subjects(test_file)
51
+ result(
52
+ score: nil,
53
+ measured: false,
54
+ confidence: :low,
55
+ findings: [
56
+ finding(
57
+ rule_id: "no_subjects",
58
+ severity: :warning,
59
+ location: test_file.test_cases.first&.location,
60
+ message: "No mutation subjects were inferred for this test file.",
61
+ suggestion: "Add an explicit mapping entry with mutation subjects or SUT paths."
62
+ )
63
+ ]
64
+ )
65
+ end
66
+
67
+ # Paper basis: Jia/Harman 2011 discusses equivalent mutants and
68
+ # operational mutation-analysis threats such as timeouts/sampling.
69
+ def operational_findings(matrix, context)
70
+ findings = warning_findings(matrix)
71
+ if matrix.sampled?
72
+ findings << finding(
73
+ rule_id: "sampled_mutants",
74
+ severity: :info,
75
+ location: nil,
76
+ message: "Mutation analysis used a deterministic sample because generated mutants exceeded the configured limit.",
77
+ suggestion: "Increase mutation.max_mutants or run a narrower target for full precision."
78
+ )
79
+ end
80
+ if matrix.timeout_ratio > 0.2
81
+ findings << finding(
82
+ rule_id: "timeout_rate_high",
83
+ severity: :warning,
84
+ location: nil,
85
+ message: "More than 20% of counted mutants timed out.",
86
+ suggestion: "Increase mutation.timeout or inspect timeout-prone code before trusting the score."
87
+ )
88
+ end
89
+ threshold = context.config_fetch(:mutation, :equivalent_threshold, default: 0.2).to_f
90
+ if matrix.equivalent_ratio > threshold
91
+ findings << finding(
92
+ rule_id: "equivalent_rate_high",
93
+ severity: :warning,
94
+ location: nil,
95
+ message: "Equivalent mutant annotations exceed the configured threshold.",
96
+ suggestion: "Review equivalent annotations to avoid hiding real surviving mutants."
97
+ )
98
+ end
99
+ findings
100
+ end
101
+
102
+ def warning_findings(matrix)
103
+ Array((matrix.metadata || {})["warnings"]).map do |warning|
104
+ type = warning.is_a?(Hash) ? warning.fetch("type", "mutation_warning") : "mutation_warning"
105
+ message = warning.is_a?(Hash) ? warning.fetch("message", warning.inspect) : warning.to_s
106
+ finding(
107
+ rule_id: type,
108
+ severity: :warning,
109
+ location: nil,
110
+ message: message,
111
+ suggestion: suggestion_for_warning(type)
112
+ )
113
+ end
114
+ end
115
+
116
+ def suggestion_for_warning(type)
117
+ return "Fix or quarantine the failing baseline test before trusting mutation results." if type.to_s == "unstable_baseline"
118
+
119
+ "Review mutation execution settings."
120
+ end
121
+
122
+ def confidence_for(matrix)
123
+ return :medium if matrix.sampled? || matrix.timeout_ratio > 0.2
124
+
125
+ :high
126
+ end
127
+
128
+ def mutant_message(mutant)
129
+ return "Mutant #{mutant.id} timed out." if mutant.status.to_s == "timeout"
130
+
131
+ "Mutant #{mutant.id} survived."
132
+ end
133
+
134
+ def mutant_suggestion(mutant)
135
+ return "Increase timeout or add a faster assertion that distinguishes this mutation." if mutant.status.to_s == "timeout"
136
+
137
+ mutant.killing_hint || "Add an expectation that distinguishes this mutation."
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class QualityAssertionDensity < Analyzer
8
+ analyzer_id "quality/assertion_density"
9
+ axis :quality
10
+ level 0
11
+
12
+ reference authors: "Kudrjavets, G.; Nagappan, N.; Ball, T.",
13
+ title: "Assessing the Relationship between Software Assertions and Faults",
14
+ venue: "ISSRE",
15
+ year: 2006,
16
+ doi: "10.1109/ISSRE.2006.14"
17
+
18
+ # Paper basis: Kudrjavets/Nagappan/Ball 2006
19
+ # (doi:10.1109/ISSRE.2006.14) links assertion density with fault outcomes.
20
+ def analyze(test_file, context)
21
+ executable_loc = [test_file.executable_loc, 1].max
22
+ density = test_file.assertions.length.to_f / executable_loc
23
+ density_min = context.config_fetch(:quality, :assertion_density_min, default: 0.05)
24
+ return result(score: 100.0, findings: [], confidence: :high) if density >= density_min
25
+
26
+ penalty = (1.0 - (density / density_min)) * 10.0
27
+ findings = [
28
+ finding(
29
+ rule_id: "low_assertion_density",
30
+ severity: :warning,
31
+ location: test_file.test_cases.first&.location,
32
+ message: "Assertion density is #{density.round(3)} per executable test line.",
33
+ suggestion: "Add explicit expectations for the behavior this file exercises."
34
+ )
35
+ ]
36
+
37
+ result(score: (100.0 - penalty).round(2), findings: findings, confidence: :high)
38
+ end
39
+ end
40
+ end
41
+ end