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
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,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sixth_sense"
5
+ require "sixth_sense/cli"
6
+
7
+ exit SixthSense::CLI.new.run(ARGV)
@@ -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)