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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class QualityFlakiness < Analyzer
8
+ analyzer_id "quality/flakiness"
9
+ axis :quality
10
+ level 0
11
+
12
+ reference authors: "Luo, Q.; Hariri, F.; Eloussi, L.; Marinov, D.",
13
+ title: "An Empirical Analysis of Flaky Tests",
14
+ venue: "FSE",
15
+ year: 2014,
16
+ doi: "10.1145/2635868.2635920"
17
+
18
+ # Paper basis: Luo et al. 2014 (doi:10.1145/2635868.2635920) categorizes
19
+ # flaky-test causes; this analyzer flags static triggers from those classes.
20
+ def analyze(test_file, _context)
21
+ findings = test_file.test_cases.filter_map { |test_case| flaky_finding(test_case) }
22
+ score = [100.0 - (20.0 * findings.length), 0.0].max
23
+
24
+ result(score: score.round(2), findings: findings, confidence: :high)
25
+ end
26
+
27
+ private
28
+
29
+ def flaky_finding(test_case)
30
+ rule = rule_for(test_case.body)
31
+ return unless rule
32
+
33
+ finding(
34
+ rule_id: rule,
35
+ severity: :warning,
36
+ location: test_case.location,
37
+ message: "Example contains a common flaky-test trigger: #{rule}.",
38
+ suggestion: "Replace timing, wall-clock, or global-order dependency with deterministic test control."
39
+ )
40
+ end
41
+
42
+ # Paper basis: Luo et al. 2014 identifies sleep, wall-clock time, and
43
+ # order/randomness dependencies as common flaky-test causes.
44
+ def rule_for(body)
45
+ return "sleepy_test" if body.match?(/\bsleep(?:\s|\()/)
46
+ return "wall_clock_time" if body.match?(/\b(Time\.now|Date\.today|DateTime\.now)\b/)
47
+ return "order_dependency" if body.match?(/\b(RSpec\.configuration\.order|config\.order|rand\()/)
48
+
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class QualityTestSmells < Analyzer
8
+ analyzer_id "quality/test_smells"
9
+ axis :quality
10
+ level 0
11
+
12
+ reference authors: "van Deursen, A.; Moonen, L.; van den Bergh, A.; Kok, G.",
13
+ title: "Refactoring Test Code",
14
+ venue: "XP",
15
+ year: 2001
16
+ reference authors: "Meszaros, G.",
17
+ title: "xUnit Test Patterns: Refactoring Test Code",
18
+ venue: "Addison-Wesley",
19
+ year: 2007
20
+ reference authors: "Peruma, A. et al.",
21
+ title: "tsDetect: An Open Source Test Smells Detection Tool",
22
+ venue: "ESEC/FSE",
23
+ year: 2020,
24
+ doi: "10.1145/3368089.3417921"
25
+ reference authors: "Luo, Q.; Hariri, F.; Eloussi, L.; Marinov, D.",
26
+ title: "An Empirical Analysis of Flaky Tests",
27
+ venue: "FSE",
28
+ year: 2014,
29
+ doi: "10.1145/2635868.2635920"
30
+
31
+ WEIGHTS = {
32
+ assertion_roulette: 3,
33
+ eager_test: 3,
34
+ mystery_guest: 3,
35
+ sleepy_test: 3,
36
+ general_fixture: 2,
37
+ conditional_logic: 2,
38
+ sensitive_equality: 2,
39
+ empty_or_pending: 2,
40
+ duplicate_assert: 1,
41
+ magic_number: 1
42
+ }.freeze
43
+
44
+ # Paper/book basis: van Deursen et al. 2001, Meszaros 2007, and
45
+ # tsDetect/Peruma et al. 2020 (doi:10.1145/3368089.3417921)
46
+ # define the test-smell families aggregated here.
47
+ def analyze(test_file, context)
48
+ findings = test_file.test_cases.flat_map { |test_case| findings_for(test_case, context) }
49
+ findings.reject! { |finding| disabled?(test_file, finding) }
50
+ score = quality_score(test_file, findings, context)
51
+
52
+ result(score: score, findings: findings, confidence: :high)
53
+ end
54
+
55
+ private
56
+
57
+ def findings_for(test_case, context)
58
+ [
59
+ assertion_roulette(test_case, context),
60
+ eager_test(test_case),
61
+ mystery_guest(test_case),
62
+ sensitive_equality(test_case),
63
+ conditional_logic(test_case),
64
+ sleepy_test(test_case),
65
+ empty_or_pending(test_case),
66
+ general_fixture(test_case),
67
+ duplicate_assert(test_case),
68
+ magic_number(test_case)
69
+ ].flatten.compact
70
+ end
71
+
72
+ # Reference basis: van Deursen et al. 2001 and Meszaros 2007 describe
73
+ # assertion roulette as multiple unlabeled assertions obscuring failures.
74
+ def assertion_roulette(test_case, context)
75
+ threshold = context.config_fetch(:quality, :assertion_roulette_threshold, default: 3)
76
+ threshold += 2 if test_case.metadata[:aggregate_failures] || test_case.body.include?("aggregate_failures")
77
+ unlabelled = test_case.assertions.count { |assertion| assertion.message.to_s.empty? }
78
+ return if unlabelled < threshold
79
+
80
+ finding(
81
+ rule_id: "assertion_roulette",
82
+ severity: :warning,
83
+ location: test_case.location,
84
+ message: "#{unlabelled} expectations share one example without explanatory messages.",
85
+ suggestion: "Split the example or add expectation messages so failures identify intent."
86
+ )
87
+ end
88
+
89
+ # Reference basis: van Deursen et al. 2001 and tsDetect/Peruma et al. 2020
90
+ # identify eager tests as examples exercising too many production methods.
91
+ def eager_test(test_case)
92
+ method_calls = test_case.body.scan(/\b([a-z_]\w*)\.([a-z_]\w*[!?=]?)\b/).filter_map do |receiver, method_name|
93
+ next if %w[expect is_expected to not_to to_not should should_not].include?(receiver)
94
+ next if %w[to not_to to_not should should_not eq eql equal include match be].include?(method_name)
95
+
96
+ "#{receiver}.#{method_name}"
97
+ end.uniq
98
+ return if method_calls.length < 3
99
+
100
+ finding(
101
+ rule_id: "eager_test",
102
+ severity: :warning,
103
+ location: test_case.location,
104
+ message: "Example exercises #{method_calls.length} distinct public calls.",
105
+ suggestion: "Keep one behavioral reason to fail per example where practical."
106
+ )
107
+ end
108
+
109
+ # Reference basis: van Deursen et al. 2001 and Meszaros 2007 define
110
+ # mystery guests as hidden external fixture/file dependencies.
111
+ def mystery_guest(test_case)
112
+ return unless test_case.body.match?(/\b(File\.read|YAML\.load_file|fixture_file|fixtures\/|fixtures_path)\b/)
113
+
114
+ finding(
115
+ rule_id: "mystery_guest",
116
+ severity: :warning,
117
+ location: test_case.location,
118
+ message: "Example depends directly on external fixture or file content.",
119
+ suggestion: "Route fixture setup through named factories or helpers."
120
+ )
121
+ end
122
+
123
+ # Reference basis: tsDetect/Peruma et al. 2020 includes fragile equality
124
+ # checks over stringified representations among test-smell detectors.
125
+ def sensitive_equality(test_case)
126
+ return unless test_case.body.match?(/(to_s|inspect)\)?\.to\s+(eq|eql|match)|expect\([^)]*\.(to_s|inspect)\)/)
127
+
128
+ finding(
129
+ rule_id: "sensitive_equality",
130
+ severity: :warning,
131
+ location: test_case.location,
132
+ message: "Example compares stringified object representation.",
133
+ suggestion: "Assert on stable domain attributes instead of formatting."
134
+ )
135
+ end
136
+
137
+ # Reference basis: Meszaros 2007 and tsDetect/Peruma et al. 2020 treat
138
+ # control flow in tests as a maintainability smell.
139
+ def conditional_logic(test_case)
140
+ return unless test_case.body.match?(/\b(if|case|unless|while|until|for)\b|\.each\s+do\b/)
141
+
142
+ finding(
143
+ rule_id: "conditional_logic",
144
+ severity: :warning,
145
+ location: test_case.location,
146
+ message: "Example contains control flow.",
147
+ suggestion: "Move branching setup into separate examples or helpers."
148
+ )
149
+ end
150
+
151
+ # Paper basis: Luo et al. 2014 (doi:10.1145/2635868.2635920) reports
152
+ # timing and asynchronous waits as common flaky-test triggers.
153
+ def sleepy_test(test_case)
154
+ return unless test_case.body.match?(/\bsleep(?:\s|\()|\bTime\.now\b|\bDate\.today\b/)
155
+
156
+ finding(
157
+ rule_id: "sleepy_test",
158
+ severity: :warning,
159
+ location: test_case.location,
160
+ message: "Example uses sleep or wall-clock time directly.",
161
+ suggestion: "Use deterministic waiting or time-freezing support."
162
+ )
163
+ end
164
+
165
+ # Reference basis: van Deursen et al. 2001 and Meszaros 2007 describe
166
+ # general fixtures as broad setup not used by each individual test.
167
+ def general_fixture(test_case)
168
+ fixtures = Array(test_case.metadata[:fixtures])
169
+ return if fixtures.empty?
170
+
171
+ unused = fixtures.filter_map do |fixture|
172
+ names = [fixture[:name], *fixture[:ivars]].compact.reject(&:empty?)
173
+ next if names.empty?
174
+ next if names.any? { |name| test_case.body.include?(name.to_s) }
175
+
176
+ fixture
177
+ end
178
+ return if unused.empty?
179
+
180
+ finding(
181
+ rule_id: "general_fixture",
182
+ severity: :warning,
183
+ location: test_case.location,
184
+ message: "Example does not use #{unused.length} fixture setup item(s).",
185
+ suggestion: "Move unused setup into examples that need it or narrower contexts."
186
+ )
187
+ end
188
+
189
+ # Reference basis: tsDetect/Peruma et al. 2020 includes empty and skipped
190
+ # tests among executable-test quality smells.
191
+ def empty_or_pending(test_case)
192
+ return unless test_case.metadata[:pending] || test_case.body.strip.empty? || test_case.body.match?(/\b(pending|skip)\b/)
193
+
194
+ finding(
195
+ rule_id: "empty_or_pending",
196
+ severity: :warning,
197
+ location: test_case.location,
198
+ message: "Example is empty, pending, or skipped.",
199
+ suggestion: "Remove it or turn it into an executable expectation."
200
+ )
201
+ end
202
+
203
+ # Reference basis: tsDetect/Peruma et al. 2020 motivates duplicate
204
+ # assertion detection as a low-severity maintainability smell.
205
+ def duplicate_assert(test_case)
206
+ duplicates = test_case.assertions.group_by(&:signature).select { |_signature, assertions| assertions.length > 1 }
207
+ return if duplicates.empty?
208
+
209
+ finding(
210
+ rule_id: "duplicate_assert",
211
+ severity: :info,
212
+ location: test_case.location,
213
+ message: "Example repeats #{duplicates.length} assertion signature(s).",
214
+ suggestion: "Delete duplicate expectations unless repetition documents separate behavior."
215
+ )
216
+ end
217
+
218
+ # Reference basis: Meszaros 2007 and tsDetect/Peruma et al. 2020 treat
219
+ # unexplained expected literals as a context-dependent test smell.
220
+ def magic_number(test_case)
221
+ literal_expectations = test_case.body.scan(/\b(?:eq|eql|be|be_within)\(?\s*(-?\d+(?:\.\d+)?)\b/)
222
+ return if literal_expectations.length < 3
223
+
224
+ finding(
225
+ rule_id: "magic_number",
226
+ severity: :info,
227
+ location: test_case.location,
228
+ message: "Example uses #{literal_expectations.length} numeric expected values.",
229
+ suggestion: "Name important constants or derive expected values from domain terms."
230
+ )
231
+ end
232
+
233
+ # Paper basis: Bavota et al. 2015 (doi:10.1007/s10664-014-9313-0) and
234
+ # Spadini et al. 2018 (doi:10.1109/ICSME.2018.00010) motivate weighted
235
+ # smell impact; this score keeps those weights tiered rather than learned.
236
+ def quality_score(test_file, findings, context)
237
+ cap = context.config_fetch(:quality, :smell_cap, default: 5)
238
+ penalty = findings.group_by { |item| item.rule_id.to_sym }.sum do |rule_id, rule_findings|
239
+ WEIGHTS.fetch(rule_id, 1) * [rule_findings.length, cap].min
240
+ end
241
+ loc_norm = [test_file.executable_loc / 100.0, 1.0].max
242
+ quality_raw = 100.0 - (12.0 * penalty / loc_norm)
243
+ [[quality_raw, 0.0].max, 100.0].min.round(2)
244
+ end
245
+
246
+ def disabled?(test_file, finding)
247
+ source = File.file?(test_file.path) ? File.read(test_file.path) : ""
248
+ directives = source.scan(/#\s*sixth_sense:disable\s+([A-Za-z0-9_\/,-]+)/).flatten.flat_map { |item| item.split(",") }
249
+ directives.include?(finding.rule_id) || directives.include?(finding.analyzer_id) || directives.include?("all")
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analyzer"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class RedundancyClone < Analyzer
8
+ analyzer_id "redundancy/clone"
9
+ axis :redundancy
10
+ level 0
11
+
12
+ reference authors: "Roy, C. K.; Cordy, J. R.",
13
+ title: "A Survey on Software Clone Detection Research",
14
+ venue: "Queen's University Technical Report",
15
+ year: 2007
16
+
17
+ # Paper basis: Roy/Cordy 2007 surveys Type-1/2 clone detection; this
18
+ # groups normalized test bodies as a low-confidence clone signal.
19
+ def analyze(test_file, _context)
20
+ groups = test_file.test_cases.group_by { |test_case| normalize_body(test_case.body) }
21
+ clone_groups = groups.reject { |normalized, cases| normalized.empty? || cases.length < 2 }
22
+ duplicate_count = clone_groups.values.sum { |cases| cases.length - 1 }
23
+ score = 100.0 * (1.0 - duplicate_count.to_f / [test_file.test_cases.length, 1].max)
24
+ findings = clone_groups.values.flat_map do |cases|
25
+ cases.drop(1).map do |test_case|
26
+ finding(
27
+ rule_id: "test_clone",
28
+ severity: :info,
29
+ location: test_case.location,
30
+ message: "Example body is a Type-1/2 clone of another example in this file.",
31
+ suggestion: "Extract setup or merge examples only if the behavioral intent is identical."
32
+ )
33
+ end
34
+ end
35
+
36
+ result(score: score.round(2), findings: findings, confidence: :low)
37
+ end
38
+
39
+ private
40
+
41
+ # Paper basis: Roy/Cordy 2007 Type-2 clones allow renamed literals/tokens,
42
+ # so literals are normalized before duplicate grouping.
43
+ def normalize_body(body)
44
+ body.to_s
45
+ .gsub(/#.*$/, "")
46
+ .gsub(/(["']).*?\1/, "STR")
47
+ .gsub(/:\w+/, ":SYM")
48
+ .gsub(/\b-?\d+(?:\.\d+)?\b/, "NUM")
49
+ .gsub(/\s+/, " ")
50
+ .strip
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redundancy_requirement"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class RedundancyCoverage < Analyzer
8
+ include RedundancyRequirement
9
+
10
+ analyzer_id "redundancy/coverage_based"
11
+ axis :redundancy
12
+ level 1
13
+
14
+ reference authors: "Harrold, M. J.; Gupta, R.; Soffa, M. L.",
15
+ title: "A Methodology for Controlling the Size of a Test Suite",
16
+ venue: "ACM TOSEM",
17
+ year: 1993,
18
+ doi: "10.1145/152388.152391"
19
+ reference authors: "Rothermel, G.; Harrold, M. J.; Ostrin, J.; Hong, C.",
20
+ title: "An Empirical Study of the Effects of Minimization on the Fault Detection Capabilities of Test Suites",
21
+ venue: "ICSM",
22
+ year: 1998,
23
+ doi: "10.1109/ICSM.1998.738487"
24
+
25
+ # Paper basis: Harrold/Gupta/Soffa 1993 (doi:10.1145/152388.152391)
26
+ # models minimization through requirement coverage; Rothermel et al. 1998
27
+ # (doi:10.1109/ICSM.1998.738487) motivates conservative reporting.
28
+ def analyze(test_file, context)
29
+ coverage = context.coverage_map_for(test_file)
30
+ return unmeasured(confidence: :low) unless coverage
31
+
32
+ requirements = test_file.test_cases.to_h do |test_case|
33
+ [test_case.id, coverage.branch_requirements_for(test_case.id)]
34
+ end
35
+ analyze_requirements(test_file, requirements, confidence: :medium)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redundancy_requirement"
4
+
5
+ module SixthSense
6
+ module Analyzers
7
+ class RedundancyMutation < Analyzer
8
+ include RedundancyRequirement
9
+
10
+ analyzer_id "redundancy/mutation_based"
11
+ axis :redundancy
12
+ level 2
13
+
14
+ reference authors: "Koochakzadeh, N.; Garousi, V.",
15
+ title: "A Tester-Assisted Methodology for Test Redundancy Detection",
16
+ venue: "Advances in Software Engineering",
17
+ year: 2010,
18
+ doi: "10.1155/2010/932686"
19
+ reference authors: "Yoo, S.; Harman, M.",
20
+ title: "Regression Testing Minimization, Selection and Prioritization: A Survey",
21
+ venue: "STVR",
22
+ year: 2012,
23
+ doi: "10.1002/stvr.430"
24
+
25
+ # Paper basis: Koochakzadeh/Garousi 2010 (doi:10.1155/2010/932686)
26
+ # uses mutation kill sets for redundancy; Yoo/Harman 2012
27
+ # (doi:10.1002/stvr.430) surveys minimization/selection tradeoffs.
28
+ def analyze(test_file, context)
29
+ matrix = context.kill_matrix_for(test_file)
30
+ return unmeasured(confidence: :low) unless matrix&.matrix?
31
+
32
+ requirements = test_file.test_cases.to_h do |test_case|
33
+ [test_case.id, matrix.kill_requirements_for(test_case.id)]
34
+ end
35
+ analyze_requirements(test_file, requirements, confidence: :high)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "../analyzer"
6
+
7
+ module SixthSense
8
+ module Analyzers
9
+ module RedundancyRequirement
10
+ private
11
+
12
+ # Paper basis: Harrold/Gupta/Soffa 1993 reduces suite size by retaining
13
+ # representatives that cover requirements; Rothermel et al. 1998 motivates
14
+ # preserving oracle-distinct tests instead of blindly deleting them.
15
+ def analyze_requirements(test_file, requirements_by_id, confidence:)
16
+ return result(score: 100.0, findings: [], confidence: confidence) if test_file.test_cases.empty?
17
+
18
+ selected_ids = greedy_representatives(test_file, requirements_by_id)
19
+ redundant = test_file.test_cases.reject { |test_case| selected_ids.include?(test_case.id) }
20
+ penalties = redundant.to_h do |test_case|
21
+ [test_case, oracle_penalty(test_case, test_file, selected_ids)]
22
+ end
23
+ score = 100.0 * (1.0 - penalties.values.sum / [test_file.test_cases.length, 1].max)
24
+ findings = penalties.map do |test_case, penalty|
25
+ finding(
26
+ rule_id: "redundant_candidate",
27
+ severity: :warning,
28
+ location: test_case.location,
29
+ message: "Example is covered by the representative requirement set.",
30
+ suggestion: penalty >= 1.0 ? "Review for consolidation." : "Coverage overlaps, but assertions differ; review manually."
31
+ )
32
+ end
33
+
34
+ result(score: [[score, 0.0].max, 100.0].min.round(2), findings: findings, confidence: confidence)
35
+ end
36
+
37
+ # Paper basis: Harrold/Gupta/Soffa 1993; greedy set-cover approximation
38
+ # selects tests that cover the most still-uncovered requirements.
39
+ def greedy_representatives(test_file, requirements_by_id)
40
+ uncovered = requirements_by_id.values.reduce(Set.new, :|)
41
+ selected = Set.new
42
+
43
+ until uncovered.empty?
44
+ candidate = test_file.test_cases.max_by do |test_case|
45
+ new_coverage = requirements_by_id.fetch(test_case.id, Set.new) & uncovered
46
+ [new_coverage.length, test_case.assertions.length, -(test_case.location&.line || 0)]
47
+ end
48
+ break unless candidate
49
+
50
+ covered_now = requirements_by_id.fetch(candidate.id, Set.new) & uncovered
51
+ break if covered_now.empty?
52
+
53
+ selected << candidate.id
54
+ uncovered -= covered_now
55
+ end
56
+
57
+ selected
58
+ end
59
+
60
+ # Paper basis: Rothermel et al. 1998; overlap is penalized less when the
61
+ # assertion/oracle signature differs, preserving possible fault detection.
62
+ def oracle_penalty(test_case, test_file, selected_ids)
63
+ signature_set = test_case.assertions.map(&:signature).to_set
64
+ selected_signature_sets = test_file.test_cases.select { |item| selected_ids.include?(item.id) }
65
+ .map { |item| item.assertions.map(&:signature).to_set }
66
+ selected_signature_sets.include?(signature_set) ? 1.0 : 0.5
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module SixthSense
6
+ class ChangedFiles
7
+ def self.test_files(diff_ref)
8
+ new(diff_ref).test_files
9
+ end
10
+
11
+ def initialize(diff_ref)
12
+ @diff_ref = diff_ref
13
+ end
14
+
15
+ def test_files
16
+ names = changed_names
17
+ specs = names.select { |path| path.end_with?("_spec.rb") && File.file?(path) }
18
+ inferred_specs = names.flat_map { |path| infer_spec_paths(path) }
19
+ (specs + inferred_specs).uniq.sort
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :diff_ref
25
+
26
+ def changed_names
27
+ output, status = Open3.capture2("git", "diff", "--name-only", diff_ref)
28
+ return [] unless status.success?
29
+
30
+ output.lines.map(&:strip).reject(&:empty?)
31
+ end
32
+
33
+ def infer_spec_paths(path)
34
+ return [] unless path.start_with?("lib/", "app/")
35
+ return [] unless path.end_with?(".rb")
36
+
37
+ relative = path.sub(%r{\A(?:lib|app)/}, "").sub(/\.rb\z/, "_spec.rb")
38
+ candidates = [File.join("spec", relative)]
39
+ if path.start_with?("app/")
40
+ candidates << File.join("spec", path.sub(%r{\Aapp/}, "").sub(/\.rb\z/, "_spec.rb"))
41
+ end
42
+ candidates.select { |candidate| File.file?(candidate) }
43
+ end
44
+ end
45
+ end