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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/exe/sixth_sense +7 -0
- data/lib/sixth_sense/adapters/minitest.rb +145 -0
- data/lib/sixth_sense/adapters/rspec.rb +373 -0
- data/lib/sixth_sense/adapters/test_unit.rb +142 -0
- data/lib/sixth_sense/analysis_context.rb +35 -0
- data/lib/sixth_sense/analysis_runner.rb +141 -0
- data/lib/sixth_sense/analyzer.rb +85 -0
- data/lib/sixth_sense/analyzers/adequacy_checked_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/adequacy_coverage.rb +63 -0
- data/lib/sixth_sense/analyzers/adequacy_mutation.rb +141 -0
- data/lib/sixth_sense/analyzers/quality_assertion_density.rb +41 -0
- data/lib/sixth_sense/analyzers/quality_flakiness.rb +53 -0
- data/lib/sixth_sense/analyzers/quality_test_smells.rb +253 -0
- data/lib/sixth_sense/analyzers/redundancy_clone.rb +54 -0
- data/lib/sixth_sense/analyzers/redundancy_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_mutation.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_requirement.rb +70 -0
- data/lib/sixth_sense/changed_files.rb +45 -0
- data/lib/sixth_sense/cli.rb +229 -0
- data/lib/sixth_sense/config.rb +91 -0
- data/lib/sixth_sense/engines/mutant.rb +258 -0
- data/lib/sixth_sense/framework_adapter.rb +55 -0
- data/lib/sixth_sense/guardrail/baseline.rb +135 -0
- data/lib/sixth_sense/guardrail/evaluator.rb +69 -0
- data/lib/sixth_sense/model.rb +264 -0
- data/lib/sixth_sense/mutation_cache.rb +93 -0
- data/lib/sixth_sense/mutation_engine.rb +52 -0
- data/lib/sixth_sense/mutation_matrix_mutant_generator.rb +462 -0
- data/lib/sixth_sense/mutation_matrix_producer.rb +308 -0
- data/lib/sixth_sense/mutation_score_cache_writer.rb +75 -0
- data/lib/sixth_sense/rake_task.rb +16 -0
- data/lib/sixth_sense/reporters/console.rb +31 -0
- data/lib/sixth_sense/reporters/html.rb +62 -0
- data/lib/sixth_sense/reporters/json.rb +18 -0
- data/lib/sixth_sense/reporters/markdown.rb +34 -0
- data/lib/sixth_sense/reporters/sarif.rb +77 -0
- data/lib/sixth_sense/result.rb +86 -0
- data/lib/sixth_sense/runners/checked_coverage_estimator.rb +62 -0
- data/lib/sixth_sense/runners/checked_coverage_runner.rb +130 -0
- data/lib/sixth_sense/runners/checked_coverage_trace.rb +110 -0
- data/lib/sixth_sense/runners/coverage_runner.rb +220 -0
- data/lib/sixth_sense/runners/coverage_snapshot.rb +64 -0
- data/lib/sixth_sense/runners/minitest_checked_coverage_probe.rb +42 -0
- data/lib/sixth_sense/runners/minitest_coverage_probe.rb +49 -0
- data/lib/sixth_sense/runners/rspec_checked_coverage_probe.rb +26 -0
- data/lib/sixth_sense/runners/rspec_coverage_probe.rb +98 -0
- data/lib/sixth_sense/runners/test_unit_checked_coverage_probe.rb +43 -0
- data/lib/sixth_sense/runners/test_unit_coverage_probe.rb +51 -0
- data/lib/sixth_sense/scoring/aggregator.rb +117 -0
- data/lib/sixth_sense/source_location.rb +19 -0
- data/lib/sixth_sense/version.rb +5 -0
- data/lib/sixth_sense.rb +74 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7d460a31490dfd9d4ac8c096fbcb86afdad696ba0beafef3e8893e6fbf81aa6a
|
|
4
|
+
data.tar.gz: 75850592152aa8d123d7265402113db56a7cf2316a15092890b60d7ebc0fd6ee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5c0960e29d12302ed481552c384994a0e05adb9f493b9850751c1c029d5753fdf0df96e77147ae574db2697dce4e8d26df348ce38abbd8b78bbb3d58f71ff5b8
|
|
7
|
+
data.tar.gz: b0bd4de300fbf8fde89299f04da91f49bc10816ce1a59c60ddecfdf7a83765451ef319f0f15300ff9100ecb4f2302d520b065aad8676d4fe67cdeba3bc452fdc
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# SixthSense
|
|
2
|
+
|
|
3
|
+
Evidence-based test suite adequacy analysis for Ruby projects.
|
|
4
|
+
|
|
5
|
+
SixthSense scores test files across three axes:
|
|
6
|
+
|
|
7
|
+
- Adequacy: coverage, checked coverage, and mutation score where available.
|
|
8
|
+
- Redundancy: coverage- and mutation-requirement overlap, plus static clone detection.
|
|
9
|
+
- Quality: test smells, assertion density, and flaky-test triggers.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add the gem to your application's Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "sixth_sense"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then install dependencies:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For local development from this repository:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle install
|
|
29
|
+
rake test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Run a static quality and redundancy pass:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
ruby -Ilib exe/sixth_sense analyze spec --level 0
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Run coverage and checked-coverage analysis:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
ruby -Ilib exe/sixth_sense analyze spec --level 1 --format json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run Level 2 analysis after producing mutation cache data:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
ruby -Ilib exe/sixth_sense mutation-run spec --mutation-mode matrix
|
|
50
|
+
ruby -Ilib exe/sixth_sense analyze spec --level 2
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use guard mode in CI:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
ruby -Ilib exe/sixth_sense guard --diff origin/main
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Explain an analyzer and its references:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
ruby -Ilib exe/sixth_sense explain adequacy/mutation
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Capabilities
|
|
66
|
+
|
|
67
|
+
- RSpec, Minitest, and test-unit discovery and parsing.
|
|
68
|
+
- Per-example coverage through subprocess probes, plus in-process coverage mode.
|
|
69
|
+
- TracePoint checked coverage with windowed and strict modes.
|
|
70
|
+
- Built-in mutation matrix producer for RSpec, Minitest, and test-unit.
|
|
71
|
+
- External `mutant` score ingestion when available.
|
|
72
|
+
- Console, JSON, Markdown, HTML, and SARIF reports.
|
|
73
|
+
- Baseline ratcheting for gradual adoption.
|
|
74
|
+
- Plugin hooks for custom adapters and analyzers.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Create `.sixth_sense.yml` when defaults need to be adjusted:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
guardrail:
|
|
82
|
+
fail_under:
|
|
83
|
+
adequacy: 70
|
|
84
|
+
redundancy: 60
|
|
85
|
+
quality: 75
|
|
86
|
+
require_level: 1
|
|
87
|
+
ratchet: true
|
|
88
|
+
tolerance: 1.0
|
|
89
|
+
coverage:
|
|
90
|
+
enabled: true
|
|
91
|
+
load_path: lib
|
|
92
|
+
checked_mode: windowed # windowed | strict
|
|
93
|
+
checked_window: 20
|
|
94
|
+
adequacy:
|
|
95
|
+
coverage_transform: raw # raw | calibrated
|
|
96
|
+
coverage_gamma: 0.5
|
|
97
|
+
mutation:
|
|
98
|
+
cache_dir: .sixth_sense_cache/kill_matrix
|
|
99
|
+
mode: score # score | matrix
|
|
100
|
+
score_engine: auto # auto | mutant | builtin
|
|
101
|
+
timeout: 10
|
|
102
|
+
timeout_policy: killed # killed | separate
|
|
103
|
+
max_mutants: 500
|
|
104
|
+
equivalent_threshold: 0.2
|
|
105
|
+
operators:
|
|
106
|
+
exclude: []
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
Run the test suite:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
rake test
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Build the gem locally:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
gem build sixth_sense.gemspec
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
SixthSense is available under the MIT License.
|
data/exe/sixth_sense
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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 Minitest < FrameworkAdapter
|
|
13
|
+
ASSERTION_PREFIXES = %w[assert refute].freeze
|
|
14
|
+
|
|
15
|
+
def handles?(path)
|
|
16
|
+
return false unless path.end_with?("_test.rb")
|
|
17
|
+
return false unless File.file?(path)
|
|
18
|
+
|
|
19
|
+
source = File.read(path)
|
|
20
|
+
return false if source.match?(/\bTest::Unit::TestCase\b/)
|
|
21
|
+
|
|
22
|
+
source.match?(/\b(Minitest::Test|assert_|refute_|assert\b|refute\b)/)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse(path)
|
|
26
|
+
parsed = Prism.parse_file(path)
|
|
27
|
+
unless parsed.success?
|
|
28
|
+
messages = parsed.errors.map(&:message).join(", ")
|
|
29
|
+
raise Error, "failed to parse #{path}: #{messages}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Model::TestFile.new(
|
|
33
|
+
path: path,
|
|
34
|
+
framework: :minitest,
|
|
35
|
+
test_cases: Parser.new(path, parsed.value).test_cases,
|
|
36
|
+
sut_candidates: map_to_sut_path(path),
|
|
37
|
+
metadata: {}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_with_coverage(test_files:, isolation:)
|
|
42
|
+
require_relative "../runners/coverage_runner"
|
|
43
|
+
|
|
44
|
+
Runners::CoverageRunner.new.run(test_files: test_files, isolation: isolation)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mutation_session(test_files:)
|
|
48
|
+
{ framework: :minitest, test_files: test_files.map(&:path), runner: "ruby" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def map_to_sut(test_file)
|
|
52
|
+
map_to_sut_path(test_file.path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def map_to_sut_path(test_path)
|
|
58
|
+
relative = test_path.tr("\\", "/").sub(%r{\A(?:.*?/)?test/}, "").sub(/_test\.rb\z/, ".rb")
|
|
59
|
+
root = project_root_for(test_path)
|
|
60
|
+
candidates = [File.join("lib", relative), File.join("app", relative), relative]
|
|
61
|
+
if root
|
|
62
|
+
candidates << File.join(root, "lib", relative)
|
|
63
|
+
candidates << File.join(root, "app", relative)
|
|
64
|
+
end
|
|
65
|
+
candidates.uniq.filter_map do |candidate|
|
|
66
|
+
next unless File.file?(candidate)
|
|
67
|
+
|
|
68
|
+
Adapters::RSpec::CodeUnitExtractor.new(candidate).code_unit
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def project_root_for(test_path)
|
|
73
|
+
normalized = test_path.tr("\\", "/")
|
|
74
|
+
match = %r{\A(.+?)/test/}.match(normalized)
|
|
75
|
+
match&.[](1)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Parser
|
|
79
|
+
def initialize(path, ast)
|
|
80
|
+
@path = path
|
|
81
|
+
@ast = ast
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_cases
|
|
85
|
+
cases = []
|
|
86
|
+
walk(@ast) do |node|
|
|
87
|
+
next unless node.is_a?(Prism::DefNode)
|
|
88
|
+
next unless node.name.to_s.start_with?("test_")
|
|
89
|
+
|
|
90
|
+
cases << test_case_for(node)
|
|
91
|
+
end
|
|
92
|
+
cases
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def test_case_for(node)
|
|
98
|
+
body = node.slice.to_s
|
|
99
|
+
Model::TestCase.new(
|
|
100
|
+
id: "#{@path}:#{node.name}",
|
|
101
|
+
description: node.name.to_s.tr("_", " "),
|
|
102
|
+
location: location_for(node),
|
|
103
|
+
assertions: assertions_for(node),
|
|
104
|
+
ast: node,
|
|
105
|
+
body: body,
|
|
106
|
+
metadata: {}
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def assertions_for(node)
|
|
111
|
+
assertions = []
|
|
112
|
+
walk(node) do |child|
|
|
113
|
+
next unless child.is_a?(Prism::CallNode)
|
|
114
|
+
next unless assertion_call?(child.name)
|
|
115
|
+
|
|
116
|
+
assertions << Model::Assertion.new(
|
|
117
|
+
location: location_for(child),
|
|
118
|
+
matcher: child.name,
|
|
119
|
+
subject_expr: child.arguments&.arguments&.map(&:slice)&.join(", "),
|
|
120
|
+
message: nil
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
assertions
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assertion_call?(name)
|
|
127
|
+
ASSERTION_PREFIXES.any? { |prefix| name.to_s.start_with?(prefix) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def walk(node, &block)
|
|
131
|
+
return unless node
|
|
132
|
+
|
|
133
|
+
yield node
|
|
134
|
+
node.each_child_node { |child| walk(child, &block) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def location_for(node)
|
|
138
|
+
SourceLocation.new(path: @path, line: node.location.start_line, column: node.location.start_column + 1)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
SixthSense::FrameworkAdapter.register(:minitest, SixthSense::Adapters::Minitest)
|
|
@@ -0,0 +1,373 @@
|
|
|
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
|
+
|
|
9
|
+
module SixthSense
|
|
10
|
+
module Adapters
|
|
11
|
+
class RSpec < FrameworkAdapter
|
|
12
|
+
GROUP_NAMES = %i[describe context feature].freeze
|
|
13
|
+
EXAMPLE_NAMES = %i[it specify example scenario].freeze
|
|
14
|
+
PENDING_EXAMPLE_NAMES = %i[xit xspecify xexample pending skip].freeze
|
|
15
|
+
SHARED_GROUP_NAMES = %i[shared_examples shared_examples_for shared_context].freeze
|
|
16
|
+
SHARED_USAGE_NAMES = %i[it_behaves_like it_should_behave_like include_examples include_context].freeze
|
|
17
|
+
FIXTURE_NAMES = %i[let let! subject subject! before around].freeze
|
|
18
|
+
|
|
19
|
+
def handles?(path)
|
|
20
|
+
return false unless path.end_with?("_spec.rb")
|
|
21
|
+
return false unless File.file?(path)
|
|
22
|
+
|
|
23
|
+
source = File.read(path)
|
|
24
|
+
source.match?(/\b(RSpec\.)?(describe|context|it|specify|example)\b/)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse(path)
|
|
28
|
+
source = File.read(path)
|
|
29
|
+
parsed = Prism.parse(source)
|
|
30
|
+
unless parsed.success?
|
|
31
|
+
messages = parsed.errors.map(&:message).join(", ")
|
|
32
|
+
raise Error, "failed to parse #{path}: #{messages}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
test_file = Model::TestFile.new(
|
|
36
|
+
path: path,
|
|
37
|
+
framework: :rspec,
|
|
38
|
+
test_cases: Parser.new(path, parsed.value).test_cases,
|
|
39
|
+
sut_candidates: [],
|
|
40
|
+
metadata: {}
|
|
41
|
+
)
|
|
42
|
+
test_file.sut_candidates = map_to_sut(test_file)
|
|
43
|
+
test_file
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_with_coverage(test_files:, isolation:)
|
|
47
|
+
require_relative "../runners/coverage_runner"
|
|
48
|
+
|
|
49
|
+
Runners::CoverageRunner.new.run(test_files: test_files, isolation: isolation)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mutation_session(test_files:)
|
|
53
|
+
{
|
|
54
|
+
framework: :rspec,
|
|
55
|
+
test_files: test_files.map(&:path),
|
|
56
|
+
runner: "mutant-rspec"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def map_to_sut(test_file)
|
|
61
|
+
conventional_sut_paths(test_file.path).filter_map do |candidate|
|
|
62
|
+
next unless File.file?(candidate)
|
|
63
|
+
|
|
64
|
+
CodeUnitExtractor.new(candidate).code_unit
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assertion_patterns
|
|
69
|
+
%i[expect is_expected should should_not]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def conventional_sut_paths(spec_path)
|
|
75
|
+
relative = spec_relative_path(spec_path).sub(/_spec\.rb\z/, ".rb")
|
|
76
|
+
root = project_root_for(spec_path)
|
|
77
|
+
candidates = [
|
|
78
|
+
File.join("lib", relative),
|
|
79
|
+
File.join("app", relative),
|
|
80
|
+
relative
|
|
81
|
+
]
|
|
82
|
+
if root
|
|
83
|
+
candidates << File.join(root, "lib", relative)
|
|
84
|
+
candidates << File.join(root, "app", relative)
|
|
85
|
+
end
|
|
86
|
+
candidates.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def spec_relative_path(spec_path)
|
|
90
|
+
normalized = spec_path.tr("\\", "/")
|
|
91
|
+
return normalized.sub(%r{\Aspec/}, "") if normalized.start_with?("spec/")
|
|
92
|
+
|
|
93
|
+
normalized.sub(%r{\A.*?/spec/}, "")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def project_root_for(spec_path)
|
|
97
|
+
normalized = spec_path.tr("\\", "/")
|
|
98
|
+
match = %r{\A(.+?)/spec/}.match(normalized)
|
|
99
|
+
match&.[](1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Parser
|
|
103
|
+
def initialize(path, ast)
|
|
104
|
+
@path = path
|
|
105
|
+
@ast = ast
|
|
106
|
+
@shared_examples = {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_cases
|
|
110
|
+
cases = []
|
|
111
|
+
process_statements(@ast.statements, [], [], [], cases)
|
|
112
|
+
cases
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def process_statements(statements, index_stack, descriptions, fixtures, cases)
|
|
118
|
+
return unless statements
|
|
119
|
+
|
|
120
|
+
counter = 0
|
|
121
|
+
statements.body.each do |node|
|
|
122
|
+
if call_node?(node) && shared_definition_call?(node)
|
|
123
|
+
@shared_examples[description_for(node)] = {
|
|
124
|
+
body: node.block&.body,
|
|
125
|
+
descriptions: descriptions + [description_for(node)],
|
|
126
|
+
fixtures: fixtures.dup
|
|
127
|
+
}
|
|
128
|
+
elsif call_node?(node) && fixture_call?(node)
|
|
129
|
+
fixtures << fixture_metadata(node)
|
|
130
|
+
elsif call_node?(node) && (group_call?(node) || example_call?(node) || shared_usage_call?(node))
|
|
131
|
+
counter += 1
|
|
132
|
+
process_rspec_call(node, index_stack + [counter], descriptions, fixtures, cases)
|
|
133
|
+
else
|
|
134
|
+
process_nested(node, index_stack, descriptions, fixtures, cases)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def process_nested(node, index_stack, descriptions, fixtures, cases)
|
|
140
|
+
node.each_child_node do |child|
|
|
141
|
+
if child.respond_to?(:body) && child.body.respond_to?(:body)
|
|
142
|
+
process_statements(child.body, index_stack, descriptions, fixtures.dup, cases)
|
|
143
|
+
elsif child.respond_to?(:statements)
|
|
144
|
+
process_statements(child.statements, index_stack, descriptions, fixtures.dup, cases)
|
|
145
|
+
else
|
|
146
|
+
process_nested(child, index_stack, descriptions, fixtures, cases)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def process_rspec_call(node, index_stack, descriptions, fixtures, cases)
|
|
152
|
+
if group_call?(node)
|
|
153
|
+
group_description = description_for(node)
|
|
154
|
+
process_statements(node.block&.body, index_stack, descriptions + [group_description], fixtures.dup, cases)
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if shared_usage_call?(node)
|
|
159
|
+
expand_shared_usage(node, index_stack, descriptions, fixtures, cases)
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
cases << build_test_case(node, index_stack, descriptions, fixtures)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def expand_shared_usage(node, index_stack, descriptions, fixtures, cases)
|
|
167
|
+
shared = @shared_examples[description_for(node)]
|
|
168
|
+
unless shared
|
|
169
|
+
cases << build_test_case(node, index_stack, descriptions, fixtures)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
process_statements(
|
|
174
|
+
shared[:body],
|
|
175
|
+
index_stack,
|
|
176
|
+
descriptions + [description_for(node)],
|
|
177
|
+
fixtures + shared[:fixtures],
|
|
178
|
+
cases
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_test_case(node, index_stack, descriptions, fixtures)
|
|
183
|
+
body_node = node.block&.body
|
|
184
|
+
description = (descriptions + [description_for(node)]).compact.reject(&:empty?).join(" ")
|
|
185
|
+
body = node.block&.slice.to_s
|
|
186
|
+
assertions = body_node ? extract_assertions(body_node) : []
|
|
187
|
+
metadata = metadata_for(node)
|
|
188
|
+
metadata[:fixtures] = fixtures.dup
|
|
189
|
+
metadata[:shared_usage] = true if shared_usage_call?(node)
|
|
190
|
+
metadata[:pending] = true if PENDING_EXAMPLE_NAMES.include?(node.name) || (node.block.nil? && !shared_usage_call?(node))
|
|
191
|
+
|
|
192
|
+
Model::TestCase.new(
|
|
193
|
+
id: "#{@path}[#{index_stack.join(":")}]",
|
|
194
|
+
description: description,
|
|
195
|
+
location: location_for(node),
|
|
196
|
+
assertions: assertions,
|
|
197
|
+
ast: node,
|
|
198
|
+
body: body,
|
|
199
|
+
metadata: metadata
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def extract_assertions(node)
|
|
204
|
+
assertions = []
|
|
205
|
+
walk(node) do |child|
|
|
206
|
+
assertion = assertion_from(child)
|
|
207
|
+
assertions << assertion if assertion
|
|
208
|
+
end
|
|
209
|
+
assertions
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def assertion_from(node)
|
|
213
|
+
return unless call_node?(node)
|
|
214
|
+
|
|
215
|
+
if %i[to not_to to_not].include?(node.name) && expect_receiver?(node.receiver)
|
|
216
|
+
matcher_node = node.arguments&.arguments&.first
|
|
217
|
+
return Model::Assertion.new(
|
|
218
|
+
location: location_for(node),
|
|
219
|
+
matcher: matcher_name(matcher_node),
|
|
220
|
+
subject_expr: subject_expression(node.receiver),
|
|
221
|
+
message: expectation_message(node.receiver)
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
return unless %i[should should_not].include?(node.name)
|
|
226
|
+
|
|
227
|
+
matcher_node = node.arguments&.arguments&.first
|
|
228
|
+
Model::Assertion.new(
|
|
229
|
+
location: location_for(node),
|
|
230
|
+
matcher: matcher_name(matcher_node),
|
|
231
|
+
subject_expr: node.receiver&.slice.to_s,
|
|
232
|
+
message: nil
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def expect_receiver?(receiver)
|
|
237
|
+
call_node?(receiver) && %i[expect is_expected].include?(receiver.name)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def subject_expression(receiver)
|
|
241
|
+
return "is_expected" if receiver.name == :is_expected
|
|
242
|
+
|
|
243
|
+
receiver.arguments&.arguments&.first&.slice.to_s
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def expectation_message(receiver)
|
|
247
|
+
receiver.arguments&.arguments&.[](1)&.slice&.delete_prefix("\"")&.delete_suffix("\"")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def matcher_name(node)
|
|
251
|
+
return :unknown unless node
|
|
252
|
+
return node.name if call_node?(node)
|
|
253
|
+
|
|
254
|
+
node.slice.to_sym
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def walk(node, &block)
|
|
258
|
+
return unless node
|
|
259
|
+
|
|
260
|
+
yield node
|
|
261
|
+
node.each_child_node { |child| walk(child, &block) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def metadata_for(node)
|
|
265
|
+
args = node.arguments&.arguments || []
|
|
266
|
+
args.drop(1).each_with_object({}) do |arg, metadata|
|
|
267
|
+
case arg
|
|
268
|
+
when Prism::SymbolNode
|
|
269
|
+
metadata[arg.unescaped.to_sym] = true
|
|
270
|
+
when Prism::KeywordHashNode, Prism::HashNode
|
|
271
|
+
metadata[:hash] = arg.slice
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def fixture_metadata(node)
|
|
277
|
+
{
|
|
278
|
+
type: node.name,
|
|
279
|
+
name: fixture_name(node),
|
|
280
|
+
ivars: node.block&.slice.to_s.scan(/@\w+/).uniq,
|
|
281
|
+
body: node.block&.slice.to_s,
|
|
282
|
+
location: location_for(node).to_h
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def fixture_name(node)
|
|
287
|
+
first = node.arguments&.arguments&.first
|
|
288
|
+
return nil unless first
|
|
289
|
+
return first.unescaped.to_s if first.respond_to?(:unescaped)
|
|
290
|
+
|
|
291
|
+
first.slice.to_s
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def description_for(node)
|
|
295
|
+
first = node.arguments&.arguments&.first
|
|
296
|
+
return "" unless first
|
|
297
|
+
return first.unescaped if first.respond_to?(:unescaped)
|
|
298
|
+
|
|
299
|
+
first.slice.to_s
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def group_call?(node)
|
|
303
|
+
GROUP_NAMES.include?(node.name)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def example_call?(node)
|
|
307
|
+
EXAMPLE_NAMES.include?(node.name) || PENDING_EXAMPLE_NAMES.include?(node.name)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def shared_usage_call?(node)
|
|
311
|
+
SHARED_USAGE_NAMES.include?(node.name)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def shared_definition_call?(node)
|
|
315
|
+
SHARED_GROUP_NAMES.include?(node.name)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def fixture_call?(node)
|
|
319
|
+
FIXTURE_NAMES.include?(node.name)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def call_node?(node)
|
|
323
|
+
node.is_a?(Prism::CallNode)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def location_for(node)
|
|
327
|
+
SourceLocation.new(
|
|
328
|
+
path: @path,
|
|
329
|
+
line: node.location.start_line,
|
|
330
|
+
column: node.location.start_column + 1
|
|
331
|
+
)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
class CodeUnitExtractor
|
|
336
|
+
def initialize(path)
|
|
337
|
+
@path = path
|
|
338
|
+
@ast = Prism.parse_file(path).value
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def code_unit
|
|
342
|
+
constants = []
|
|
343
|
+
methods = []
|
|
344
|
+
walk(@ast) do |node|
|
|
345
|
+
constants << constant_name(node) if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
346
|
+
methods << node.name.to_s if node.is_a?(Prism::DefNode)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
Model::CodeUnit.new(
|
|
350
|
+
path: @path,
|
|
351
|
+
constants: constants.compact.uniq,
|
|
352
|
+
methods: methods.compact.uniq
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
def constant_name(node)
|
|
359
|
+
node.constant_path.slice
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def walk(node, &block)
|
|
363
|
+
return unless node
|
|
364
|
+
|
|
365
|
+
yield node
|
|
366
|
+
node.each_child_node { |child| walk(child, &block) }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
SixthSense::FrameworkAdapter.register(:rspec, SixthSense::Adapters::RSpec)
|