saboteur 0.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +145 -0
  5. data/exe/saboteur +12 -0
  6. data/lib/saboteur/cli.rb +112 -0
  7. data/lib/saboteur/config.rb +133 -0
  8. data/lib/saboteur/engine/boot.rb +83 -0
  9. data/lib/saboteur/engine/candidates.rb +63 -0
  10. data/lib/saboteur/engine/coverage_store.rb +99 -0
  11. data/lib/saboteur/engine/diagnostics.rb +37 -0
  12. data/lib/saboteur/engine/diff_scope.rb +32 -0
  13. data/lib/saboteur/engine/distribution.rb +74 -0
  14. data/lib/saboteur/engine/result_cache.rb +93 -0
  15. data/lib/saboteur/engine/scheduler.rb +215 -0
  16. data/lib/saboteur/engine/subject_selection.rb +63 -0
  17. data/lib/saboteur/engine/suite_files.rb +82 -0
  18. data/lib/saboteur/engine/verdict_check.rb +78 -0
  19. data/lib/saboteur/engine.rb +133 -0
  20. data/lib/saboteur/execution/process_table.rb +177 -0
  21. data/lib/saboteur/execution/process_tree.rb +99 -0
  22. data/lib/saboteur/execution/protocol.rb +41 -0
  23. data/lib/saboteur/execution/reevaluator.rb +144 -0
  24. data/lib/saboteur/execution/runner.rb +191 -0
  25. data/lib/saboteur/execution/supervisor.rb +337 -0
  26. data/lib/saboteur/execution/worker_pool.rb +249 -0
  27. data/lib/saboteur/frameworks/rails.rb +166 -0
  28. data/lib/saboteur/hooks.rb +51 -0
  29. data/lib/saboteur/ignore_list.rb +62 -0
  30. data/lib/saboteur/integration/base.rb +72 -0
  31. data/lib/saboteur/integration/rspec.rb +143 -0
  32. data/lib/saboteur/mutation_source/base.rb +26 -0
  33. data/lib/saboteur/mutation_source/builtin.rb +232 -0
  34. data/lib/saboteur/mutation_source/edit.rb +18 -0
  35. data/lib/saboteur/mutation_source/mutation.rb +28 -0
  36. data/lib/saboteur/reporter/diff.rb +97 -0
  37. data/lib/saboteur/reporter/stryker.rb +116 -0
  38. data/lib/saboteur/reporter/terminal.rb +136 -0
  39. data/lib/saboteur/result.rb +134 -0
  40. data/lib/saboteur/selection/coverage_map.rb +113 -0
  41. data/lib/saboteur/selection/git_diff.rb +64 -0
  42. data/lib/saboteur/selection/recorder.rb +72 -0
  43. data/lib/saboteur/subject.rb +166 -0
  44. data/lib/saboteur/subject_finder.rb +98 -0
  45. data/lib/saboteur/version.rb +6 -0
  46. data/lib/saboteur.rb +51 -0
  47. metadata +130 -0
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "subject"
6
+
7
+ module Saboteur
8
+ # Finds `def` methods in a Ruby file with Prism.
9
+ #
10
+ # Included: instance methods and `def self.x` inside a class or module,
11
+ # defs inside `class << self`, and defs passed to a receiverless call such
12
+ # as `private def x`. Skipped: top-level defs, defs nested in defs, defs in
13
+ # blocks (`Class.new do`, `included do`), and `def obj.x` for obj != self.
14
+ class SubjectFinder
15
+ Parsed = Data.define(:subjects, :errors)
16
+
17
+ def call(file)
18
+ source = File.binread(file)
19
+ result = Prism.parse(source, filepath: file)
20
+ return Parsed.new(subjects: [], errors: result.errors.map(&:message)) if result.failure?
21
+
22
+ frozen = frozen_literals?(result)
23
+ subjects = []
24
+ walk(result.value, [], subjects, file: file, source: source, frozen: frozen)
25
+ mark_refinements(subjects, result.value)
26
+ Parsed.new(subjects: subjects, errors: [])
27
+ end
28
+
29
+ private
30
+
31
+ def frozen_literals?(result)
32
+ result.magic_comments.any? do |comment|
33
+ comment.key.tr("-", "_").casecmp?("frozen_string_literal") && comment.value.casecmp?("true")
34
+ end
35
+ end
36
+
37
+ def walk(node, nesting, out, **ctx)
38
+ case node
39
+ when nil, Prism::BlockNode, Prism::LambdaNode
40
+ nil
41
+ when Prism::ModuleNode
42
+ walk(node.body, nesting + [Scope.new(kind: :module, path: node.constant_path.slice)], out, **ctx)
43
+ when Prism::ClassNode
44
+ walk(node.body, nesting + [Scope.new(kind: :class, path: node.constant_path.slice)], out, **ctx)
45
+ when Prism::SingletonClassNode
46
+ walk_singleton_class(node, nesting, out, **ctx)
47
+ when Prism::DefNode
48
+ add_subject(node, nesting, out, **ctx)
49
+ else
50
+ node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) }
51
+ end
52
+ end
53
+
54
+ def walk_singleton_class(node, nesting, out, **ctx)
55
+ return unless node.expression.is_a?(Prism::SelfNode)
56
+ return if nesting.empty? || nesting.last.sclass?
57
+
58
+ walk(node.body, nesting + [Scope.new(kind: :sclass, path: "self")], out, **ctx)
59
+ end
60
+
61
+ def add_subject(node, nesting, out, file:, source:, frozen:)
62
+ in_sclass = nesting.last&.sclass? || false
63
+ # `def self.x` inside `class << self`, and `def obj.x`, are not subjects.
64
+ return unless node.receiver.nil? || (node.receiver.is_a?(Prism::SelfNode) && !in_sclass)
65
+
66
+ singleton = in_sclass || !node.receiver.nil?
67
+ owner = owner_path(nesting)
68
+ return unless owner
69
+
70
+ out << Subject.new(name: node.name, owner: owner, singleton: singleton, sclass: in_sclass,
71
+ file: file, node: node, file_source: source, nesting: nesting,
72
+ frozen_literals: frozen)
73
+ end
74
+
75
+ # Lexical path of the enclosing class/module. `class ::Foo` restarts at the top.
76
+ def owner_path(nesting)
77
+ parts = nesting.reject(&:sclass?).map(&:path)
78
+ return nil if parts.empty?
79
+
80
+ parts.each_with_object([]) do |path, acc|
81
+ acc.clear if path.start_with?("::")
82
+ acc << path.delete_prefix("::")
83
+ end.join("::")
84
+ end
85
+
86
+ def mark_refinements(subjects, program)
87
+ return unless uses_refinements?(program)
88
+
89
+ subjects.each { |subject| subject.skip_reason = "refinements" }
90
+ end
91
+
92
+ def uses_refinements?(node)
93
+ return true if node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
94
+
95
+ node.compact_child_nodes.any? { |child| uses_refinements?(child) }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ VERSION = "0.2.0"
6
+ end
data/lib/saboteur.rb ADDED
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "saboteur/version"
5
+
6
+ module Saboteur
7
+ class Error < StandardError; end
8
+
9
+ # Invalid configuration or input files (exit code 2).
10
+ class ConfigError < Error; end
11
+
12
+ class << self
13
+ # Set while hook files from config load, so they can call Saboteur.hook.
14
+ attr_accessor :current_hooks
15
+
16
+ # Registers a lifecycle hook from a hooks file:
17
+ #
18
+ # Saboteur.hook(:after_fork) { Redis.current.disconnect! }
19
+ # Saboteur.hook(:around_mutant) { |mutation, run| Timecop.freeze { run.call } }
20
+ def hook(name, callable = nil, &)
21
+ raise Error, "Saboteur.hook is only available while hook files load" unless current_hooks
22
+
23
+ current_hooks.register(name, callable, &)
24
+ end
25
+ end
26
+ end
27
+
28
+ require_relative "saboteur/hooks"
29
+ require_relative "saboteur/mutation_source/base"
30
+ require_relative "saboteur/mutation_source/builtin"
31
+ require_relative "saboteur/integration/base"
32
+ require_relative "saboteur/subject"
33
+ require_relative "saboteur/subject_finder"
34
+ require_relative "saboteur/execution/protocol"
35
+ require_relative "saboteur/execution/process_table"
36
+ require_relative "saboteur/execution/process_tree"
37
+ require_relative "saboteur/execution/supervisor"
38
+ require_relative "saboteur/execution/reevaluator"
39
+ require_relative "saboteur/execution/runner"
40
+ require_relative "saboteur/execution/worker_pool"
41
+ require_relative "saboteur/selection/coverage_map"
42
+ require_relative "saboteur/selection/recorder"
43
+ require_relative "saboteur/selection/git_diff"
44
+ require_relative "saboteur/result"
45
+ require_relative "saboteur/config"
46
+ require_relative "saboteur/ignore_list"
47
+ require_relative "saboteur/frameworks/rails"
48
+ require_relative "saboteur/reporter/diff"
49
+ require_relative "saboteur/reporter/terminal"
50
+ require_relative "saboteur/reporter/stryker"
51
+ require_relative "saboteur/engine"
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: saboteur
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Vamsi Pavan Mahesh Gunturu
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: fiddle
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: prism
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.2'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '2'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '1.2'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '2'
47
+ description: |
48
+ saboteur makes small deliberate changes to your methods, runs only the tests
49
+ that cover each change, and reports the changes no test notices.
50
+ email:
51
+ - gvpmahesh@gmail.com
52
+ executables:
53
+ - saboteur
54
+ extensions: []
55
+ extra_rdoc_files: []
56
+ files:
57
+ - CHANGELOG.md
58
+ - LICENSE
59
+ - README.md
60
+ - exe/saboteur
61
+ - lib/saboteur.rb
62
+ - lib/saboteur/cli.rb
63
+ - lib/saboteur/config.rb
64
+ - lib/saboteur/engine.rb
65
+ - lib/saboteur/engine/boot.rb
66
+ - lib/saboteur/engine/candidates.rb
67
+ - lib/saboteur/engine/coverage_store.rb
68
+ - lib/saboteur/engine/diagnostics.rb
69
+ - lib/saboteur/engine/diff_scope.rb
70
+ - lib/saboteur/engine/distribution.rb
71
+ - lib/saboteur/engine/result_cache.rb
72
+ - lib/saboteur/engine/scheduler.rb
73
+ - lib/saboteur/engine/subject_selection.rb
74
+ - lib/saboteur/engine/suite_files.rb
75
+ - lib/saboteur/engine/verdict_check.rb
76
+ - lib/saboteur/execution/process_table.rb
77
+ - lib/saboteur/execution/process_tree.rb
78
+ - lib/saboteur/execution/protocol.rb
79
+ - lib/saboteur/execution/reevaluator.rb
80
+ - lib/saboteur/execution/runner.rb
81
+ - lib/saboteur/execution/supervisor.rb
82
+ - lib/saboteur/execution/worker_pool.rb
83
+ - lib/saboteur/frameworks/rails.rb
84
+ - lib/saboteur/hooks.rb
85
+ - lib/saboteur/ignore_list.rb
86
+ - lib/saboteur/integration/base.rb
87
+ - lib/saboteur/integration/rspec.rb
88
+ - lib/saboteur/mutation_source/base.rb
89
+ - lib/saboteur/mutation_source/builtin.rb
90
+ - lib/saboteur/mutation_source/edit.rb
91
+ - lib/saboteur/mutation_source/mutation.rb
92
+ - lib/saboteur/reporter/diff.rb
93
+ - lib/saboteur/reporter/stryker.rb
94
+ - lib/saboteur/reporter/terminal.rb
95
+ - lib/saboteur/result.rb
96
+ - lib/saboteur/selection/coverage_map.rb
97
+ - lib/saboteur/selection/git_diff.rb
98
+ - lib/saboteur/selection/recorder.rb
99
+ - lib/saboteur/subject.rb
100
+ - lib/saboteur/subject_finder.rb
101
+ - lib/saboteur/version.rb
102
+ homepage: https://github.com/vamsipavanmahesh/saboteur
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ homepage_uri: https://github.com/vamsipavanmahesh/saboteur
107
+ source_code_uri: https://github.com/vamsipavanmahesh/saboteur
108
+ changelog_uri: https://github.com/vamsipavanmahesh/saboteur/blob/main/CHANGELOG.md
109
+ bug_tracker_uri: https://github.com/vamsipavanmahesh/saboteur/issues
110
+ rubygems_mfa_required: 'true'
111
+ post_install_message:
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.3'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.5.22
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Mutation testing for Ruby, driven by per-test coverage.
130
+ test_files: []