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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "prism"
5
+
6
+ module SixthSense
7
+ MatrixMutation = Struct.new(:id, :path, :line, :operator, :original, :replacement, :status, :metadata, keyword_init: true)
8
+
9
+ class MutationMatrixMutantGenerator
10
+ BUILTIN_OPERATORS = %w[
11
+ arithmetic_operator_replacement
12
+ boolean_flip
13
+ collection_literal_empty
14
+ collection_literal_replacement
15
+ comparison_operator_replacement
16
+ condition_negation
17
+ constant_replacement
18
+ logical_operator_replacement
19
+ loop_condition_negation
20
+ method_call_replacement
21
+ nil_replacement
22
+ numeric_literal_decrement
23
+ numeric_literal_increment
24
+ numeric_literal_negation
25
+ numeric_literal_one
26
+ numeric_literal_zero
27
+ predicate_method_replacement
28
+ range_operator_replacement
29
+ regexp_literal_empty
30
+ return_value_nil
31
+ string_literal_empty
32
+ string_literal_replacement
33
+ symbol_literal_replacement
34
+ ].freeze
35
+
36
+ METHOD_REPLACEMENTS = {
37
+ "__send__" => %w[public_send],
38
+ "all?" => %w[any? none?],
39
+ "any?" => %w[all? empty? none?],
40
+ "append" => %w[prepend],
41
+ "assoc" => %w[rassoc],
42
+ "at" => %w[fetch key?],
43
+ "bytes" => %w[chars],
44
+ "capitalize!" => %w[capitalize],
45
+ "chars" => %w[bytes],
46
+ "chomp!" => %w[chomp],
47
+ "chop!" => %w[chop],
48
+ "chunk" => %w[each],
49
+ "chunk_while" => %w[each],
50
+ "collect" => %w[each],
51
+ "collect!" => %w[collect],
52
+ "collect_concat" => %w[collect],
53
+ "compact!" => %w[compact],
54
+ "ceil" => %w[floor],
55
+ "count" => %w[size length],
56
+ "delete!" => %w[delete],
57
+ "delete_if" => %w[reject],
58
+ "detect" => %w[first last],
59
+ "downcase" => %w[upcase],
60
+ "downcase!" => %w[downcase],
61
+ "drop" => %w[take],
62
+ "each_cons" => %w[each],
63
+ "each_key" => %w[each_value],
64
+ "each_slice" => %w[each],
65
+ "each_value" => %w[each_key],
66
+ "each_with_index" => %w[each],
67
+ "each_with_object" => %w[each],
68
+ "empty?" => %w[any?],
69
+ "encode!" => %w[encode],
70
+ "end_with?" => %w[start_with?],
71
+ "even?" => %w[odd?],
72
+ "fetch" => %w[key?],
73
+ "filter" => %w[reject],
74
+ "filter!" => %w[filter],
75
+ "filter_map" => %w[map],
76
+ "find" => %w[first last],
77
+ "first" => %w[last],
78
+ "flat_map" => %w[map],
79
+ "flatten!" => %w[flatten],
80
+ "floor" => %w[ceil],
81
+ "grep" => %w[grep_v],
82
+ "grep_v" => %w[grep],
83
+ "gsub" => %w[sub],
84
+ "gsub!" => %w[gsub],
85
+ "include?" => %w[exclude?],
86
+ "is_a?" => %w[instance_of?],
87
+ "keep_if" => %w[select],
88
+ "key?" => %w[value?],
89
+ "keys" => %w[values],
90
+ "kind_of?" => %w[instance_of?],
91
+ "last" => %w[first],
92
+ "lstrip!" => %w[lstrip],
93
+ "map" => %w[each],
94
+ "map!" => %w[map],
95
+ "match" => %w[match?],
96
+ "max" => %w[first last min],
97
+ "max_by" => %w[first last min_by],
98
+ "merge!" => %w[merge],
99
+ "method" => %w[public_method],
100
+ "min" => %w[first last max],
101
+ "min_by" => %w[first last max_by],
102
+ "negative?" => %w[positive?],
103
+ "none?" => %w[any? all?],
104
+ "odd?" => %w[even?],
105
+ "pop" => %w[shift],
106
+ "positive?" => %w[negative?],
107
+ "pred" => %w[succ],
108
+ "prepend" => %w[append],
109
+ "push" => %w[unshift],
110
+ "rassoc" => %w[assoc],
111
+ "reject" => %w[select],
112
+ "reject!" => %w[reject],
113
+ "reverse!" => %w[reverse],
114
+ "reverse_each" => %w[each],
115
+ "reverse_map" => %w[map each],
116
+ "reverse_merge" => %w[merge],
117
+ "rotate!" => %w[rotate],
118
+ "rstrip!" => %w[rstrip],
119
+ "sample" => %w[first last],
120
+ "scrub!" => %w[scrub],
121
+ "select" => %w[reject],
122
+ "select!" => %w[select],
123
+ "send" => %w[public_send __send__],
124
+ "shift" => %w[pop],
125
+ "shuffle!" => %w[shuffle],
126
+ "slice_after" => %w[each],
127
+ "slice_before" => %w[each],
128
+ "slice_when" => %w[each],
129
+ "size" => %w[length count],
130
+ "sort!" => %w[sort],
131
+ "sort_by" => %w[sort],
132
+ "sort_by!" => %w[sort_by],
133
+ "squeeze!" => %w[squeeze],
134
+ "start_with?" => %w[end_with?],
135
+ "strip" => %w[lstrip rstrip],
136
+ "strip!" => %w[strip],
137
+ "sub!" => %w[sub],
138
+ "succ" => %w[pred],
139
+ "swapcase!" => %w[swapcase],
140
+ "take" => %w[drop],
141
+ "to_a" => %w[to_ary],
142
+ "to_f" => %w[to_i],
143
+ "to_h" => %w[to_hash],
144
+ "to_i" => %w[to_int],
145
+ "to_s" => %w[to_str],
146
+ "tr!" => %w[tr],
147
+ "tr_s!" => %w[tr_s],
148
+ "transform_keys" => %w[transform_values],
149
+ "transform_keys!" => %w[transform_keys],
150
+ "transform_values" => %w[transform_keys],
151
+ "transform_values!" => %w[transform_values],
152
+ "unicode_normalize!" => %w[unicode_normalize],
153
+ "uniq!" => %w[uniq],
154
+ "unshift" => %w[push],
155
+ "upcase" => %w[downcase],
156
+ "upcase!" => %w[upcase],
157
+ "values" => %w[keys],
158
+ "values_at" => %w[fetch_values],
159
+ "zero?" => %w[nonzero?]
160
+ }.freeze
161
+
162
+ def initialize(config: {})
163
+ @config = config
164
+ end
165
+
166
+ def generate(plan)
167
+ generated = generate_mutants(plan)
168
+ skipped, executable = generated.partition { |mutant| operator_excluded?(mutant.operator) }
169
+ selected, sampling = select_mutants(executable)
170
+ {
171
+ mutants: skipped.map { |mutant| mark_skipped(mutant) } + selected,
172
+ sampling: sampling.merge("excluded_count" => skipped.length)
173
+ }
174
+ end
175
+
176
+ private
177
+
178
+ def generate_mutants(plan)
179
+ plan.source_units.flat_map do |unit|
180
+ path = unit.fetch(:path) { unit["path"] }
181
+ next [] unless File.file?(path)
182
+
183
+ mutants_for_file(path)
184
+ end.flatten
185
+ end
186
+
187
+ def mutants_for_file(path)
188
+ source = File.read(path)
189
+ parsed = Prism.parse(source)
190
+ return [] unless parsed.success?
191
+
192
+ mutations = []
193
+ walk(parsed.value) do |node, parent|
194
+ mutations.concat(node_mutations(path, node, parent))
195
+ end
196
+ mutations.compact.sort_by { |mutant| [mutant.metadata.dig("range", "start"), mutant.operator, mutant.replacement] }
197
+ end
198
+
199
+ def walk(node, parent = nil, &block)
200
+ yield node, parent
201
+ node.each_child_node { |child| walk(child, node, &block) } if node.respond_to?(:each_child_node)
202
+ end
203
+
204
+ def node_mutations(path, node, parent)
205
+ case node
206
+ when Prism::StringNode then string_mutations(path, node)
207
+ when Prism::IntegerNode, Prism::FloatNode then numeric_mutations(path, node)
208
+ when Prism::TrueNode, Prism::FalseNode then boolean_mutations(path, node)
209
+ when Prism::IfNode, Prism::UnlessNode then conditional_mutations(path, node)
210
+ when Prism::WhileNode, Prism::UntilNode then loop_mutations(path, node)
211
+ when Prism::AndNode, Prism::OrNode then logical_mutations(path, node)
212
+ when Prism::NilNode then [range_mutation(path, "nil_replacement", node.location, "false")]
213
+ when Prism::ReturnNode then return_mutations(path, node)
214
+ when Prism::DefNode then implicit_return_mutations(path, node)
215
+ when Prism::SymbolNode then symbol_mutations(path, node)
216
+ when Prism::ArrayNode, Prism::HashNode then collection_mutations(path, node)
217
+ when Prism::RangeNode then range_operator_mutations(path, node)
218
+ when Prism::RegularExpressionNode then [range_mutation(path, "regexp_literal_empty", node.location, "//")]
219
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then constant_mutations(path, node, parent)
220
+ when Prism::CallNode then call_mutations(path, node)
221
+ else []
222
+ end
223
+ end
224
+
225
+ def string_mutations(path, node)
226
+ delimiter = node.opening == "'" ? "'" : '"'
227
+ [
228
+ range_mutation(path, "string_literal_replacement", node.location, "#{delimiter}#{node.unescaped}__mutant__#{delimiter}"),
229
+ range_mutation(path, "string_literal_empty", node.location, "#{delimiter}#{delimiter}")
230
+ ]
231
+ end
232
+
233
+ def numeric_mutations(path, node)
234
+ value = numeric_value(node.slice)
235
+ [
236
+ range_mutation(path, "numeric_literal_increment", node.location, numeric_literal(value + 1)),
237
+ range_mutation(path, "numeric_literal_decrement", node.location, numeric_literal(value - 1)),
238
+ range_mutation(path, "numeric_literal_zero", node.location, "0"),
239
+ range_mutation(path, "numeric_literal_one", node.location, "1"),
240
+ range_mutation(path, "numeric_literal_negation", node.location, numeric_literal(-value))
241
+ ]
242
+ end
243
+
244
+ def boolean_mutations(path, node)
245
+ replacement = node.is_a?(Prism::TrueNode) ? "false" : "true"
246
+ [range_mutation(path, "boolean_flip", node.location, replacement)]
247
+ end
248
+
249
+ def conditional_mutations(path, node)
250
+ location = node.respond_to?(:if_keyword_loc) ? node.if_keyword_loc : node.keyword_loc
251
+ replacement = node.is_a?(Prism::UnlessNode) || location.slice == "unless" ? "if" : "unless"
252
+ [range_mutation(path, "condition_negation", location, replacement)]
253
+ end
254
+
255
+ def loop_mutations(path, node)
256
+ replacement = node.is_a?(Prism::WhileNode) ? "until" : "while"
257
+ [range_mutation(path, "loop_condition_negation", node.keyword_loc, replacement)]
258
+ end
259
+
260
+ def logical_mutations(path, node)
261
+ replacement = node.operator == "&&" || node.operator == "and" ? (node.operator == "and" ? "or" : "||") : (node.operator == "or" ? "and" : "&&")
262
+ [range_mutation(path, "logical_operator_replacement", node.operator_loc, replacement)]
263
+ end
264
+
265
+ def return_mutations(path, node)
266
+ return [] unless node.location.slice.match?(/\Areturn\b/)
267
+
268
+ [range_mutation(path, "return_value_nil", node.location, "return nil")]
269
+ end
270
+
271
+ def implicit_return_mutations(path, node)
272
+ statements = node.body&.body
273
+ return [] if statements.nil? || statements.empty?
274
+
275
+ last_statement = statements.last
276
+ return [] if last_statement.is_a?(Prism::NilNode) || last_statement.is_a?(Prism::ReturnNode)
277
+
278
+ [range_mutation(path, "return_value_nil", last_statement.location, "nil")]
279
+ end
280
+
281
+ def symbol_mutations(path, node)
282
+ return [] unless node.location.slice.start_with?(":")
283
+
284
+ [range_mutation(path, "symbol_literal_replacement", node.location, ":#{node.value}__mutant__")]
285
+ end
286
+
287
+ def collection_mutations(path, node)
288
+ literal = node.location.slice
289
+ replacement = node.is_a?(Prism::ArrayNode) ? "[]" : "{}"
290
+ mutations = [range_mutation(path, "collection_literal_replacement", node.location, "nil")]
291
+ mutations << range_mutation(path, "collection_literal_empty", node.location, replacement) unless literal == replacement
292
+ mutations
293
+ end
294
+
295
+ def range_operator_mutations(path, node)
296
+ replacement = node.operator == "..." ? ".." : "..."
297
+ [range_mutation(path, "range_operator_replacement", node.operator_loc, replacement)]
298
+ end
299
+
300
+ def constant_mutations(path, node, parent)
301
+ return [] if parent.is_a?(Prism::ClassNode) || parent.is_a?(Prism::ModuleNode)
302
+ return [] if node.location.slice == "Object"
303
+
304
+ [range_mutation(path, "constant_replacement", node.location, "Object")]
305
+ end
306
+
307
+ def call_mutations(path, node)
308
+ [
309
+ comparison_mutations(path, node),
310
+ arithmetic_mutations(path, node),
311
+ method_call_mutations(path, node),
312
+ predicate_method_mutations(path, node)
313
+ ].flatten.compact
314
+ end
315
+
316
+ def comparison_mutations(path, node)
317
+ replacements = {
318
+ "===" => ["is_a?"],
319
+ "=~" => ["match?"],
320
+ "==" => %w[!= eql? equal?],
321
+ "!=" => %w[== eql? equal?],
322
+ ">=" => %w[> == eql? equal?],
323
+ "<=" => %w[< == eql? equal?],
324
+ ">" => %w[== eql? equal?],
325
+ "<" => %w[== eql? equal?]
326
+ }[node.name.to_s]
327
+ return [] unless replacements
328
+
329
+ replacements.filter_map do |replacement|
330
+ call_operator_mutation(path, node, "comparison_operator_replacement", replacement)
331
+ end
332
+ end
333
+
334
+ def arithmetic_mutations(path, node)
335
+ replacement = {
336
+ "**" => "*",
337
+ "+" => "-",
338
+ "-" => "+",
339
+ "*" => "/",
340
+ "/" => "*",
341
+ "%" => "/",
342
+ "<<" => ">>",
343
+ ">>" => "<<",
344
+ "&" => %w[| ^],
345
+ "|" => %w[& ^],
346
+ "^" => %w[& |]
347
+ }[node.name.to_s]
348
+ return [] unless replacement
349
+
350
+ Array(replacement).map { |item| range_mutation(path, "arithmetic_operator_replacement", node.message_loc, item) }
351
+ end
352
+
353
+ def method_call_mutations(path, node)
354
+ return [] unless node.receiver
355
+
356
+ replacements = METHOD_REPLACEMENTS[node.name.to_s]
357
+ return [] unless replacements
358
+
359
+ replacements.filter_map do |replacement|
360
+ range_mutation(path, "method_call_replacement", node.message_loc, replacement)
361
+ end
362
+ end
363
+
364
+ def predicate_method_mutations(path, node)
365
+ return [] unless node.receiver && node.name.to_s.end_with?("?")
366
+
367
+ [
368
+ range_mutation(path, "predicate_method_replacement", node.location, "true"),
369
+ range_mutation(path, "predicate_method_replacement", node.location, "false")
370
+ ]
371
+ end
372
+
373
+ def call_operator_mutation(path, node, operator, replacement)
374
+ return range_mutation(path, operator, node.message_loc, replacement) if replacement.match?(/\A[^\w]+\z/)
375
+
376
+ source = method_call_source(node, replacement)
377
+ range_mutation(path, operator, node.location, source) if source
378
+ end
379
+
380
+ def method_call_source(node, method_name)
381
+ receiver = node.receiver&.slice
382
+ return unless receiver
383
+
384
+ args = Array(node.arguments&.arguments).map(&:slice).join(", ")
385
+ args.empty? ? "#{receiver}.#{method_name}" : "#{receiver}.#{method_name}(#{args})"
386
+ end
387
+
388
+ def numeric_value(source)
389
+ normalized = source.delete("_")
390
+ normalized.include?(".") ? normalized.to_f : normalized.to_i
391
+ end
392
+
393
+ def numeric_literal(value)
394
+ value.is_a?(Float) && value.to_i != value ? value.to_s : value.to_i.to_s
395
+ end
396
+
397
+ def range_mutation(path, operator, location, replacement)
398
+ return if location.nil?
399
+ return if location.slice == replacement
400
+
401
+ digest = Digest::SHA256.hexdigest([path, location.start_offset, location.end_offset, operator, replacement].join(":"))[0, 12]
402
+ MatrixMutation.new(
403
+ id: "m-#{digest}",
404
+ path: path,
405
+ line: location.start_line,
406
+ operator: operator,
407
+ original: location.slice,
408
+ replacement: replacement,
409
+ status: "active",
410
+ metadata: {
411
+ "rewrite" => "range",
412
+ "range" => {
413
+ "start" => location.start_offset,
414
+ "end" => location.end_offset
415
+ }
416
+ }
417
+ )
418
+ end
419
+
420
+ def operator_excluded?(operator)
421
+ operator_excludes.include?(operator.to_s)
422
+ end
423
+
424
+ def operator_excludes
425
+ operators = @config["operators"] || @config[:operators] || {}
426
+ Array(operators["exclude"] || operators[:exclude]).map(&:to_s)
427
+ end
428
+
429
+ def select_mutants(mutants)
430
+ return [mutants, sampling_metadata(mutants, mutants, sampled: false)] if mutants.length <= max_mutants
431
+
432
+ selected = []
433
+ grouped = mutants.group_by(&:operator).sort_by { |operator, _items| operator.to_s }
434
+ until selected.length >= max_mutants || grouped.empty?
435
+ grouped.each do |_operator, items|
436
+ selected << items.shift if items.any? && selected.length < max_mutants
437
+ end
438
+ grouped.reject! { |_operator, items| items.empty? }
439
+ end
440
+ [selected, sampling_metadata(mutants, selected, sampled: true)]
441
+ end
442
+
443
+ def sampling_metadata(generated, selected, sampled:)
444
+ {
445
+ "sampled" => sampled,
446
+ "strategy" => sampled ? "operator_round_robin" : "none",
447
+ "generated_count" => generated.length,
448
+ "selected_count" => selected.length
449
+ }
450
+ end
451
+
452
+ def max_mutants
453
+ [@config.fetch("max_mutants", 500).to_i, 1].max
454
+ end
455
+
456
+ def mark_skipped(mutant)
457
+ mutant.status = "skipped"
458
+ mutant.metadata = mutant.metadata.merge("skip_reason" => "operator_excluded")
459
+ mutant
460
+ end
461
+ end
462
+ end