stud-finder 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/CHANGELOG.md +17 -0
- data/LICENSE +21 -0
- data/PRODUCT.md +172 -0
- data/README.md +176 -0
- data/VISION.md +151 -0
- data/bin/stud-finder +6 -0
- data/lib/stud-finder.rb +3 -0
- data/lib/stud_finder/churn.rb +111 -0
- data/lib/stud_finder/cli.rb +771 -0
- data/lib/stud_finder/complexity.rb +104 -0
- data/lib/stud_finder/coverage/cobertura.rb +59 -0
- data/lib/stud_finder/coverage/detector.rb +26 -0
- data/lib/stud_finder/coverage/lcov.rb +93 -0
- data/lib/stud_finder/coverage/resultset.rb +103 -0
- data/lib/stud_finder/diff.rb +53 -0
- data/lib/stud_finder/edges.rb +113 -0
- data/lib/stud_finder/fan_in.rb +243 -0
- data/lib/stud_finder/file_collector.rb +152 -0
- data/lib/stud_finder/js_complexity.rb +203 -0
- data/lib/stud_finder/js_fan_in.rb +121 -0
- data/lib/stud_finder/normalizer.rb +38 -0
- data/lib/stud_finder/scorer.rb +171 -0
- data/lib/stud_finder/temporal_coupling.rb +104 -0
- data/lib/stud_finder/version.rb +5 -0
- data/lib/stud_finder.rb +14 -0
- metadata +199 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module StudFinder
|
|
7
|
+
class FanIn
|
|
8
|
+
Result = Struct.new(:counts, :fan_out_counts, :edges, keyword_init: true)
|
|
9
|
+
ReferenceCandidate = Struct.new(:namespace, :name, :absolute, :candidates, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
PATH_ROOTS = %w[app lib test].freeze
|
|
12
|
+
CLASS_OR_MODULE_TYPES = %i[class module].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(repo_path:, files:, stderr: $stderr)
|
|
15
|
+
@repo_path = File.expand_path(repo_path)
|
|
16
|
+
@files = files
|
|
17
|
+
@stderr = stderr
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
constants = constant_ownership
|
|
22
|
+
references = resolved_reference_sets(@files, constants)
|
|
23
|
+
reverse_constants = constants.invert
|
|
24
|
+
|
|
25
|
+
counts = @files.to_h { |file| [file, 0] }
|
|
26
|
+
fan_out_counts = @files.to_h { |file| [file, 0] }
|
|
27
|
+
dependents = @files.to_h { |file| [file, []] }
|
|
28
|
+
dependencies = @files.to_h { |file| [file, []] }
|
|
29
|
+
|
|
30
|
+
@files.each do |file|
|
|
31
|
+
constant = constants[file]
|
|
32
|
+
counts[file] = constant ? fan_in_count(file, constant, references) : 0
|
|
33
|
+
|
|
34
|
+
references[file]&.each do |ref_constant|
|
|
35
|
+
dep_file = reverse_constants[ref_constant]
|
|
36
|
+
next unless dep_file && dep_file != file
|
|
37
|
+
|
|
38
|
+
fan_out_counts[file] += 1
|
|
39
|
+
dependencies[file] << dep_file
|
|
40
|
+
dependents[dep_file] << file
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
edges = @files.to_h do |file|
|
|
45
|
+
[file, { dependents: dependents[file].uniq, dependencies: dependencies[file].uniq }]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def constant_ownership
|
|
54
|
+
@files.filter_map do |file|
|
|
55
|
+
constant = zeitwerk_constant(file) || primary_constant(file)
|
|
56
|
+
[file, constant] if constant
|
|
57
|
+
end.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resolved_reference_sets(source_files, constants)
|
|
61
|
+
known_constants = constants.values.to_set
|
|
62
|
+
known_namespace_prefixes = namespace_prefixes(known_constants)
|
|
63
|
+
|
|
64
|
+
source_files.to_h do |file|
|
|
65
|
+
[file, resolve_references(references_for(file), known_constants, known_namespace_prefixes)]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_references(reference_candidates, known_constants,
|
|
70
|
+
known_namespace_prefixes = namespace_prefixes(known_constants))
|
|
71
|
+
reference_candidates.each_with_object(Set.new) do |reference, resolved|
|
|
72
|
+
constant = resolve_reference(reference, known_constants, known_namespace_prefixes)
|
|
73
|
+
resolved << constant if constant
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_reference(reference, known_constants, known_namespace_prefixes)
|
|
78
|
+
unless reference.is_a?(ReferenceCandidate)
|
|
79
|
+
return reference.find { |candidate| known_constants.include?(candidate) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return reference.candidates.find(&known_constants.method(:include?)) if reference.absolute
|
|
83
|
+
return reference.candidates.find(&known_constants.method(:include?)) unless reference.name.include?('::')
|
|
84
|
+
|
|
85
|
+
leading, tail = reference.name.split('::', 2)
|
|
86
|
+
|
|
87
|
+
constant_candidates(reference.namespace, leading, false).each do |root|
|
|
88
|
+
next unless known_constants.include?(root) || known_namespace_prefixes.include?(root)
|
|
89
|
+
|
|
90
|
+
constant = [root, tail].join('::')
|
|
91
|
+
return constant if known_constants.include?(constant)
|
|
92
|
+
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def namespace_prefixes(constants)
|
|
100
|
+
constants.each_with_object(Set.new) do |constant, prefixes|
|
|
101
|
+
segments = constant.split('::')
|
|
102
|
+
next if segments.length < 2
|
|
103
|
+
|
|
104
|
+
1.upto(segments.length - 1) do |length|
|
|
105
|
+
prefixes << segments.first(length).join('::')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fan_in_count(file, constant, references)
|
|
111
|
+
references.count { |source_file, source_refs| source_file != file && source_refs.include?(constant) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def primary_constant(file)
|
|
115
|
+
ast = parse(file)
|
|
116
|
+
return unless ast
|
|
117
|
+
|
|
118
|
+
node = ast.each_node(*CLASS_OR_MODULE_TYPES).find do |candidate|
|
|
119
|
+
candidate.each_ancestor.none? { |ancestor| CLASS_OR_MODULE_TYPES.include?(ancestor.type) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
constant_name(node&.identifier)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def references_for(file)
|
|
126
|
+
ast = parse(file)
|
|
127
|
+
return Set.new unless ast
|
|
128
|
+
|
|
129
|
+
ast.each_node(:const).with_object(Set.new) do |node, references|
|
|
130
|
+
next if nested_const_part?(node)
|
|
131
|
+
|
|
132
|
+
candidates = reference_candidates(node)
|
|
133
|
+
references << candidates if candidates.any?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def reference_candidates(node)
|
|
138
|
+
name = constant_name(node)
|
|
139
|
+
return [] unless name
|
|
140
|
+
|
|
141
|
+
namespace = lexical_namespace(node)
|
|
142
|
+
absolute = absolute_const_reference?(node)
|
|
143
|
+
reference_candidate_cache[[namespace, name, absolute]] ||=
|
|
144
|
+
ReferenceCandidate.new(namespace: namespace, name: name, absolute: absolute,
|
|
145
|
+
candidates: constant_candidates(namespace, name, absolute))
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
@stderr.puts "Warning: fan_in_reference_resolution_failed: #{e.class}: #{e.message}"
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def reference_candidate_cache
|
|
152
|
+
@reference_candidate_cache ||= {}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def constant_candidates(namespace, name, absolute)
|
|
156
|
+
return [name] if absolute || namespace.nil? || namespace.empty?
|
|
157
|
+
|
|
158
|
+
namespace.length.downto(1).map do |length|
|
|
159
|
+
[namespace.first(length).join('::'), name].join('::')
|
|
160
|
+
end + [name]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def lexical_namespace(node)
|
|
164
|
+
parent = node.parent
|
|
165
|
+
declaration_identifier = parent && (parent.class_type? || parent.module_type?) && parent.children[0].equal?(node)
|
|
166
|
+
superclass_identifier = parent&.class_type? && parent.children[1].equal?(node)
|
|
167
|
+
scope_to_skip = parent if declaration_identifier || superclass_identifier
|
|
168
|
+
|
|
169
|
+
node.each_ancestor.filter_map do |ancestor|
|
|
170
|
+
next unless CLASS_OR_MODULE_TYPES.include?(ancestor.type) && !ancestor.equal?(scope_to_skip)
|
|
171
|
+
|
|
172
|
+
constant_name(ancestor.identifier)
|
|
173
|
+
end.reverse
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def absolute_const_reference?(node)
|
|
177
|
+
parent = node.children.first
|
|
178
|
+
return false unless parent
|
|
179
|
+
return true if parent.cbase_type?
|
|
180
|
+
|
|
181
|
+
parent.const_type? && absolute_const_reference?(parent)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse(file)
|
|
185
|
+
source = File.read(File.join(@repo_path, file))
|
|
186
|
+
RuboCop::ProcessedSource.new(source, RUBY_VERSION.to_f, file).ast
|
|
187
|
+
rescue EncodingError, Errno::ENOENT, Parser::SyntaxError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def constant_name(node)
|
|
192
|
+
return unless node&.const_type?
|
|
193
|
+
|
|
194
|
+
node.const_name
|
|
195
|
+
rescue StandardError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def nested_const_part?(node)
|
|
200
|
+
node.each_ancestor.any?(&:const_type?)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def zeitwerk_constant(file)
|
|
204
|
+
components = path_after_root(file)
|
|
205
|
+
return unless components
|
|
206
|
+
|
|
207
|
+
components = strip_app_concerns_namespace(components)
|
|
208
|
+
components = components.reject { |component| component == 'concerns' }
|
|
209
|
+
basename = components.pop&.delete_suffix('.rb')
|
|
210
|
+
return if basename.nil? || basename.empty?
|
|
211
|
+
|
|
212
|
+
constant = (components + [basename]).map { |component| camelize(component) }.join('::')
|
|
213
|
+
return unless valid_constant_name?(constant)
|
|
214
|
+
|
|
215
|
+
constant
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def strip_app_concerns_namespace(components)
|
|
219
|
+
concerns_index = components.index('concerns')
|
|
220
|
+
return components unless concerns_index
|
|
221
|
+
|
|
222
|
+
components[(concerns_index + 1)..] || []
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def camelize(segment)
|
|
226
|
+
segment.split('_').map(&:capitalize).join
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def valid_constant_name?(constant)
|
|
230
|
+
constant.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def path_after_root(file)
|
|
234
|
+
components = file.split('/')
|
|
235
|
+
index = components.index { |component| PATH_ROOTS.include?(component) }
|
|
236
|
+
return unless index
|
|
237
|
+
|
|
238
|
+
root = components[index]
|
|
239
|
+
remaining = components[(index + 1)..]
|
|
240
|
+
root == 'app' ? remaining[1..] : remaining
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
|
|
6
|
+
module StudFinder
|
|
7
|
+
class FileCollector
|
|
8
|
+
DEFAULT_EXCLUDES = [
|
|
9
|
+
'db/schema.rb',
|
|
10
|
+
'db/migrate/**',
|
|
11
|
+
'node_modules/**',
|
|
12
|
+
'**/node_modules/**',
|
|
13
|
+
'vendor/**',
|
|
14
|
+
'**/*.min.js',
|
|
15
|
+
'tmp/**',
|
|
16
|
+
'log/**',
|
|
17
|
+
'spec/**',
|
|
18
|
+
'test/**',
|
|
19
|
+
'__tests__/**',
|
|
20
|
+
'**/__tests__/**',
|
|
21
|
+
'**/*.test.js',
|
|
22
|
+
'**/*.test.ts',
|
|
23
|
+
'**/*.test.jsx',
|
|
24
|
+
'**/*.test.tsx',
|
|
25
|
+
'**/*.spec.js',
|
|
26
|
+
'**/*.spec.ts',
|
|
27
|
+
'**/*.spec.jsx',
|
|
28
|
+
'**/*.spec.tsx'
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
EXTENSIONS = %w[.rb .js .ts .jsx .tsx].freeze
|
|
32
|
+
LANGUAGES = {
|
|
33
|
+
'.rb' => :ruby,
|
|
34
|
+
'.js' => :javascript,
|
|
35
|
+
'.jsx' => :javascript,
|
|
36
|
+
'.ts' => :typescript,
|
|
37
|
+
'.tsx' => :typescript
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
FNM_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
|
|
41
|
+
|
|
42
|
+
Result = Struct.new(:files, :languages, :default_excluded_count, :custom_excluded_count, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
class Error < StandardError; end
|
|
45
|
+
|
|
46
|
+
def initialize(path:, excludes: [], min_files: 20, stderr: $stderr)
|
|
47
|
+
@path = File.expand_path(path)
|
|
48
|
+
@excludes = excludes
|
|
49
|
+
@min_files = min_files
|
|
50
|
+
@stderr = stderr
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def collect
|
|
54
|
+
validate!
|
|
55
|
+
|
|
56
|
+
default_excluded = 0
|
|
57
|
+
custom_excluded = 0
|
|
58
|
+
languages = {}
|
|
59
|
+
files = Dir.glob(File.join(@path, '**', '*'), File::FNM_DOTMATCH)
|
|
60
|
+
.select { |file| File.file?(file) }
|
|
61
|
+
.sort
|
|
62
|
+
.filter_map do |file|
|
|
63
|
+
extension = File.extname(file)
|
|
64
|
+
next unless EXTENSIONS.include?(extension)
|
|
65
|
+
|
|
66
|
+
relative = relative_path(file)
|
|
67
|
+
|
|
68
|
+
if default_excluded?(relative, file)
|
|
69
|
+
default_excluded += 1
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if excluded_by_patterns?(relative, @excludes)
|
|
74
|
+
custom_excluded += 1
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
languages[relative] = LANGUAGES.fetch(extension)
|
|
79
|
+
relative
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if files.length < 5
|
|
83
|
+
raise Error,
|
|
84
|
+
"Error: only #{files.length} supported files found after excludes. Too few for meaningful analysis."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if files.length < @min_files
|
|
88
|
+
@stderr.puts "Warning: only #{files.length} files found. Percentile ranks are unreliable at this scale. " \
|
|
89
|
+
'Results are advisory only.'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Result.new(files: files, languages: languages, default_excluded_count: default_excluded,
|
|
93
|
+
custom_excluded_count: custom_excluded)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def validate!
|
|
99
|
+
raise Error, "Error: #{@path} does not exist." unless File.exist?(@path)
|
|
100
|
+
raise Error, "Error: #{@path} is not a directory." unless File.directory?(@path)
|
|
101
|
+
raise Error, 'Error: git not found in PATH.' unless git_available?
|
|
102
|
+
|
|
103
|
+
_stdout, _stderr, status = Open3.capture3('git', '-C', @path, 'rev-parse', '--is-inside-work-tree')
|
|
104
|
+
return if status.success?
|
|
105
|
+
|
|
106
|
+
raise Error, "Error: #{@path} is not a git repository."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def git_available?
|
|
110
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
|
|
111
|
+
git = File.join(dir, 'git')
|
|
112
|
+
File.file?(git) && File.executable?(git)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def relative_path(file)
|
|
117
|
+
Pathname.new(file).relative_path_from(Pathname.new(@path)).to_s
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def default_excluded?(relative, file)
|
|
121
|
+
excluded_by_patterns?(relative, DEFAULT_EXCLUDES) || auto_generated?(file)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def excluded_by_patterns?(relative, patterns)
|
|
125
|
+
patterns.any? { |pattern| glob_match?(pattern, relative) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def glob_match?(pattern, relative)
|
|
129
|
+
File.fnmatch(pattern, relative, FNM_FLAGS) ||
|
|
130
|
+
globstar_directory_match?(pattern, relative) ||
|
|
131
|
+
(pattern.end_with?('/**') && relative.start_with?("#{pattern.delete_suffix('/**')}/"))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def globstar_directory_match?(pattern, relative)
|
|
135
|
+
return false unless pattern.start_with?('**/') && pattern.end_with?('/**')
|
|
136
|
+
|
|
137
|
+
directory = pattern.delete_prefix('**/').delete_suffix('/**')
|
|
138
|
+
relative.start_with?("#{directory}/") || relative.include?("/#{directory}/")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def auto_generated?(file)
|
|
142
|
+
File.foreach(file) do |line|
|
|
143
|
+
next if line.strip.empty?
|
|
144
|
+
|
|
145
|
+
return line.match?(/\A\s*#\s*This file is auto-generated/i)
|
|
146
|
+
end
|
|
147
|
+
false
|
|
148
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module StudFinder
|
|
8
|
+
class JsComplexity
|
|
9
|
+
Result = Struct.new(:counts, :warnings, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
ESLINT_MISSING = 'js_eslint_missing'
|
|
12
|
+
ESLINT_FAILED = 'js_eslint_failed'
|
|
13
|
+
ESLINT_MALFORMED = 'js_eslint_malformed'
|
|
14
|
+
TS_PARSER_MISSING = 'js_ts_parser_missing'
|
|
15
|
+
TIMEOUT = 'js_eslint_timeout'
|
|
16
|
+
BATCH_SIZE = 500
|
|
17
|
+
TS_EXTENSIONS = %w[.ts .tsx].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(repo_path:, files:, js_timeout: 60, stderr: $stderr)
|
|
20
|
+
@repo_path = File.expand_path(repo_path)
|
|
21
|
+
@files = files
|
|
22
|
+
@js_timeout = js_timeout
|
|
23
|
+
@stderr = stderr
|
|
24
|
+
@warnings = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
eslint = eslint_binary
|
|
29
|
+
return missing_eslint unless eslint
|
|
30
|
+
|
|
31
|
+
major = eslint_major(eslint)
|
|
32
|
+
return missing_eslint unless major
|
|
33
|
+
|
|
34
|
+
ts_parser = ts_parser_available?
|
|
35
|
+
warn_once(TS_PARSER_MISSING) if ts_files? && !ts_parser
|
|
36
|
+
|
|
37
|
+
counts = zero_counts
|
|
38
|
+
analyzable_files(ts_parser).each_slice(BATCH_SIZE) do |batch|
|
|
39
|
+
counts.merge!(run_batch(eslint, major, ts_parser, batch)) { |_file, old, new| [old, new].max }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Result.new(counts: counts, warnings: @warnings)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def eslint_binary
|
|
48
|
+
local = File.join(@repo_path, 'node_modules/.bin/eslint')
|
|
49
|
+
return local if File.executable?(local)
|
|
50
|
+
|
|
51
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
|
|
52
|
+
candidate = File.join(dir, 'eslint')
|
|
53
|
+
return 'eslint' if File.file?(candidate) && File.executable?(candidate)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def eslint_major(eslint)
|
|
60
|
+
stdout, _stderr, status = Open3.capture3(eslint, '--version')
|
|
61
|
+
return nil unless status.success?
|
|
62
|
+
|
|
63
|
+
stdout[/v?(\d+)/, 1]&.to_i
|
|
64
|
+
rescue Errno::ENOENT
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ts_parser_available?
|
|
69
|
+
script = <<~JS
|
|
70
|
+
const parserPath = require.resolve('@typescript-eslint/parser', { paths: [process.cwd()] });
|
|
71
|
+
require(parserPath);
|
|
72
|
+
JS
|
|
73
|
+
_stdout, _stderr, status = Open3.capture3('node', '-e', script, chdir: @repo_path)
|
|
74
|
+
status.success?
|
|
75
|
+
rescue Errno::ENOENT
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ts_files?
|
|
80
|
+
@files.any? { |file| TS_EXTENSIONS.include?(File.extname(file)) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def analyzable_files(ts_parser)
|
|
84
|
+
return @files if ts_parser
|
|
85
|
+
|
|
86
|
+
@files.reject { |file| TS_EXTENSIONS.include?(File.extname(file)) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_batch(eslint, major, ts_parser, batch)
|
|
90
|
+
temp_config = nil
|
|
91
|
+
args = [eslint]
|
|
92
|
+
if major >= 9
|
|
93
|
+
temp_config = write_flat_config(ts_parser)
|
|
94
|
+
args.push('--config', temp_config)
|
|
95
|
+
else
|
|
96
|
+
args.concat(v8_flags(ts_parser))
|
|
97
|
+
end
|
|
98
|
+
args.push('--rule', '{"complexity":["error",0]}', '--format', 'json')
|
|
99
|
+
args.concat(batch)
|
|
100
|
+
|
|
101
|
+
stdout, _stderr, status = Timeout.timeout(@js_timeout) do
|
|
102
|
+
Open3.capture3(*args, chdir: @repo_path)
|
|
103
|
+
end
|
|
104
|
+
return degraded_batch(batch, ESLINT_FAILED) if status.exitstatus == 2
|
|
105
|
+
|
|
106
|
+
parse_output(stdout, batch)
|
|
107
|
+
rescue Timeout::Error
|
|
108
|
+
degraded_batch(batch, TIMEOUT)
|
|
109
|
+
ensure
|
|
110
|
+
File.delete(temp_config) if temp_config && File.exist?(temp_config)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def v8_flags(ts_parser)
|
|
114
|
+
flags = ['--no-eslintrc', '--resolve-plugins-relative-to', '.',
|
|
115
|
+
'--parser-options=ecmaVersion:2022,sourceType:module']
|
|
116
|
+
flags.push('--parser', '@typescript-eslint/parser') if ts_parser
|
|
117
|
+
flags
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write_flat_config(ts_parser)
|
|
121
|
+
path = "/tmp/stud-finder-eslint-#{Process.pid}-#{object_id}.config.mjs"
|
|
122
|
+
parser_setup = if ts_parser
|
|
123
|
+
<<~JS
|
|
124
|
+
import { createRequire } from 'node:module';
|
|
125
|
+
const require = createRequire(#{JSON.generate(File.join(@repo_path, 'package.json'))});
|
|
126
|
+
const tsParser = require('@typescript-eslint/parser');
|
|
127
|
+
JS
|
|
128
|
+
else
|
|
129
|
+
''
|
|
130
|
+
end
|
|
131
|
+
parser_option = ts_parser ? ', parser: tsParser' : ''
|
|
132
|
+
File.write(path, <<~JS)
|
|
133
|
+
#{parser_setup}
|
|
134
|
+
export default [{
|
|
135
|
+
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
136
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module'#{parser_option} },
|
|
137
|
+
rules: { complexity: ['error', 0] }
|
|
138
|
+
}];
|
|
139
|
+
JS
|
|
140
|
+
path
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_output(stdout, batch)
|
|
144
|
+
return degraded_batch(batch, ESLINT_MALFORMED) unless stdout.strip.start_with?('[')
|
|
145
|
+
|
|
146
|
+
parse_json(stdout)
|
|
147
|
+
rescue JSON::ParserError, KeyError, NoMethodError, TypeError
|
|
148
|
+
degraded_batch(batch, ESLINT_MALFORMED)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_json(stdout)
|
|
152
|
+
payload = JSON.parse(stdout)
|
|
153
|
+
raise TypeError, 'expected ESLint JSON array' unless payload.is_a?(Array)
|
|
154
|
+
|
|
155
|
+
payload.each_with_object({}) do |file_result, counts|
|
|
156
|
+
file = normalize_path(file_result.fetch('filePath'))
|
|
157
|
+
complexities = Array(file_result['messages']).filter_map do |message|
|
|
158
|
+
message['message'].to_s[/complexity of (\d+)/, 1]&.to_i
|
|
159
|
+
end
|
|
160
|
+
counts[file] = complexities.max if complexities.any?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def parse_text(stdout)
|
|
165
|
+
stdout.each_line.with_object({}) do |line, counts|
|
|
166
|
+
match = line.match(/(.+): line \d+.*complexity of (\d+)/)
|
|
167
|
+
next unless match
|
|
168
|
+
|
|
169
|
+
file = normalize_path(match[1])
|
|
170
|
+
counts[file] = [counts.fetch(file, 0), match[2].to_i].max
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_path(path)
|
|
175
|
+
expanded = File.expand_path(path, @repo_path)
|
|
176
|
+
prefix = "#{@repo_path}/"
|
|
177
|
+
return expanded.delete_prefix(prefix) if expanded.start_with?(prefix)
|
|
178
|
+
|
|
179
|
+
path.delete_prefix('./')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def missing_eslint
|
|
183
|
+
warn_once(ESLINT_MISSING)
|
|
184
|
+
Result.new(counts: zero_counts, warnings: @warnings)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def degraded_batch(batch, code)
|
|
188
|
+
warn_once(code)
|
|
189
|
+
batch.to_h { |file| [file, 0] }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def zero_counts
|
|
193
|
+
@files.to_h { |file| [file, 0] }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def warn_once(code)
|
|
197
|
+
return if @warnings.include?(code)
|
|
198
|
+
|
|
199
|
+
@warnings << code
|
|
200
|
+
@stderr.puts "Warning: #{code}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|