stud-finder 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +30 -1
- data/README.md +127 -34
- data/SIGNALS.md +143 -0
- data/lib/stud_finder/churn.rb +10 -1
- data/lib/stud_finder/cli.rb +230 -46
- data/lib/stud_finder/complexity.rb +47 -8
- data/lib/stud_finder/coverage/cobertura.rb +1 -1
- data/lib/stud_finder/coverage/lcov.rb +1 -1
- data/lib/stud_finder/coverage/resultset.rb +1 -1
- data/lib/stud_finder/dispersion_warnings.rb +20 -0
- data/lib/stud_finder/edges.rb +1 -0
- data/lib/stud_finder/fan_in.rb +35 -6
- data/lib/stud_finder/file_collector.rb +2 -1
- data/lib/stud_finder/js_fan_in.rb +49 -8
- data/lib/stud_finder/loc_counter.rb +22 -0
- data/lib/stud_finder/newness.rb +306 -0
- data/lib/stud_finder/rails_inference.rb +172 -0
- data/lib/stud_finder/scorer.rb +128 -34
- data/lib/stud_finder/temporal_coupling.rb +33 -6
- data/lib/stud_finder/version.rb +1 -1
- metadata +11 -8
- data/PRODUCT.md +0 -172
- data/VISION.md +0 -151
|
@@ -12,10 +12,13 @@ module StudFinder
|
|
|
12
12
|
|
|
13
13
|
COMPLEXITY_COP = 'Metrics/CyclomaticComplexity'
|
|
14
14
|
COMPLEXITY_PATTERN = %r{\[(\d+)/0\]}
|
|
15
|
+
INVALID_ENCODING_PATTERN = /invalid byte sequence/i
|
|
15
16
|
PARSE_ERROR_COPS = %w[Lint/Syntax].freeze
|
|
17
|
+
BATCH_SIZE = 500
|
|
16
18
|
RUBOCOP_CONFIG = <<~YAML
|
|
17
19
|
AllCops:
|
|
18
20
|
DisabledByDefault: true
|
|
21
|
+
NewCops: disable
|
|
19
22
|
Metrics/CyclomaticComplexity:
|
|
20
23
|
Enabled: true
|
|
21
24
|
Max: 0
|
|
@@ -28,11 +31,17 @@ module StudFinder
|
|
|
28
31
|
end
|
|
29
32
|
|
|
30
33
|
def call
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
counts = zero_counts
|
|
35
|
+
skipped = []
|
|
36
|
+
|
|
37
|
+
@files.each_slice(BATCH_SIZE) do |batch|
|
|
38
|
+
result = run_batch(batch)
|
|
39
|
+
counts.merge!(result.counts) { |_file, old, new| [old, new].max }
|
|
40
|
+
skipped.concat(result.skipped_files)
|
|
41
|
+
end
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
skipped.uniq.each { |file| counts.delete(file) }
|
|
44
|
+
Result.new(counts: counts, skipped_files: skipped.uniq)
|
|
36
45
|
rescue Errno::ENOENT
|
|
37
46
|
raise Error, 'Error: rubocop not found. Install it: gem install rubocop'
|
|
38
47
|
rescue JSON::ParserError => e
|
|
@@ -41,23 +50,38 @@ module StudFinder
|
|
|
41
50
|
|
|
42
51
|
private
|
|
43
52
|
|
|
44
|
-
def
|
|
53
|
+
def run_batch(batch)
|
|
54
|
+
stdout, stderr, status = run_rubocop(batch)
|
|
55
|
+
raise Error, fatal_message(stderr) if status.exitstatus == 2
|
|
56
|
+
raise Error, fatal_message(stderr) unless [0, 1].include?(status.exitstatus)
|
|
57
|
+
|
|
58
|
+
parse(stdout, batch)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_rubocop(batch)
|
|
45
62
|
Tempfile.create(['stud-finder-rubocop', '.yml']) do |config|
|
|
46
63
|
config.write(RUBOCOP_CONFIG)
|
|
47
64
|
config.close
|
|
48
65
|
|
|
66
|
+
# Supplying an explicit temp config prevents RuboCop from loading the
|
|
67
|
+
# target repo's .rubocop.yml while preserving our Max: 0 complexity rule.
|
|
68
|
+
# RuboCop 1.88 has --force-default-config, but that option also ignores
|
|
69
|
+
# explicit --config settings, so it cannot be combined with this custom
|
|
70
|
+
# analysis config.
|
|
49
71
|
Open3.capture3(
|
|
50
72
|
'rubocop',
|
|
51
73
|
'--config', config.path,
|
|
52
74
|
'--format', 'json',
|
|
53
|
-
|
|
75
|
+
'--',
|
|
76
|
+
*batch,
|
|
77
|
+
chdir: @repo_path
|
|
54
78
|
)
|
|
55
79
|
end
|
|
56
80
|
end
|
|
57
81
|
|
|
58
|
-
def parse(stdout)
|
|
82
|
+
def parse(stdout, batch)
|
|
59
83
|
payload = JSON.parse(stdout)
|
|
60
|
-
counts =
|
|
84
|
+
counts = batch.to_h { |file| [file, 0] }
|
|
61
85
|
skipped = []
|
|
62
86
|
file_set = counts.keys.to_h { |file| [file, true] }
|
|
63
87
|
|
|
@@ -66,6 +90,11 @@ module StudFinder
|
|
|
66
90
|
next unless file_set[relative]
|
|
67
91
|
|
|
68
92
|
offenses = Array(entry['offenses'])
|
|
93
|
+
if invalid_encoding_error?(offenses)
|
|
94
|
+
counts[relative] = 0
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
69
98
|
if parse_error?(offenses)
|
|
70
99
|
skipped << relative
|
|
71
100
|
counts.delete(relative)
|
|
@@ -79,6 +108,10 @@ module StudFinder
|
|
|
79
108
|
Result.new(counts: counts, skipped_files: skipped)
|
|
80
109
|
end
|
|
81
110
|
|
|
111
|
+
def zero_counts
|
|
112
|
+
@files.to_h { |file| [file, 0] }
|
|
113
|
+
end
|
|
114
|
+
|
|
82
115
|
def complexity_score(offense)
|
|
83
116
|
return 0 unless offense['cop_name'] == COMPLEXITY_COP
|
|
84
117
|
|
|
@@ -89,6 +122,12 @@ module StudFinder
|
|
|
89
122
|
offenses.any? { |offense| PARSE_ERROR_COPS.include?(offense['cop_name']) || offense['fatal'] == true }
|
|
90
123
|
end
|
|
91
124
|
|
|
125
|
+
def invalid_encoding_error?(offenses)
|
|
126
|
+
offenses.any? do |offense|
|
|
127
|
+
parse_error?([offense]) && offense.fetch('message', '').match?(INVALID_ENCODING_PATTERN)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
92
131
|
def normalize_path(path)
|
|
93
132
|
absolute = File.expand_path(path, @repo_path)
|
|
94
133
|
absolute.start_with?("#{@repo_path}/") ? absolute.delete_prefix("#{@repo_path}/") : path
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StudFinder
|
|
4
|
+
module DispersionWarnings
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def build(files:, pcts:, raw_sources:)
|
|
8
|
+
raw_sources.filter_map do |signal, raw_source|
|
|
9
|
+
"insufficient_dispersion_#{signal}" if insufficient_dispersion?(files, pcts.fetch(signal), raw_source)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def insufficient_dispersion?(files, pct_map, raw_source)
|
|
14
|
+
values = files.map { |file| pct_map.fetch(file, 0.0).to_f }
|
|
15
|
+
raw_values = files.map { |file| raw_source.fetch(file, 0).to_f }
|
|
16
|
+
|
|
17
|
+
values.any? && values.all?(&:zero?) && raw_values.any? { |value| !value.zero? }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/stud_finder/edges.rb
CHANGED
|
@@ -54,6 +54,7 @@ module StudFinder
|
|
|
54
54
|
score: format_score(row[:score]), class: row[:classification],
|
|
55
55
|
fan_in: row[:fan_in], fan_out: row[:fan_out],
|
|
56
56
|
instability: format_score(row[:instability]))
|
|
57
|
+
@stdout.puts " escalation: #{row[:escalation]}" if row[:escalation] && !row[:escalation].empty?
|
|
57
58
|
end
|
|
58
59
|
@stdout.puts
|
|
59
60
|
end
|
data/lib/stud_finder/fan_in.rb
CHANGED
|
@@ -2,22 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rubocop'
|
|
4
4
|
require 'set'
|
|
5
|
+
require_relative 'rails_inference'
|
|
5
6
|
|
|
6
7
|
module StudFinder
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
9
|
class FanIn
|
|
8
|
-
Result = Struct.new(:counts, :fan_out_counts, :edges, keyword_init: true)
|
|
10
|
+
Result = Struct.new(:counts, :fan_out_counts, :edges, :warnings, keyword_init: true)
|
|
9
11
|
ReferenceCandidate = Struct.new(:namespace, :name, :absolute, :candidates, keyword_init: true)
|
|
10
12
|
|
|
11
13
|
PATH_ROOTS = %w[app lib test].freeze
|
|
12
14
|
CLASS_OR_MODULE_TYPES = %i[class module].freeze
|
|
13
15
|
|
|
14
|
-
def initialize(repo_path:, files:, stderr: $stderr)
|
|
16
|
+
def initialize(repo_path:, files:, stderr: $stderr, rails_inference: true)
|
|
15
17
|
@repo_path = File.expand_path(repo_path)
|
|
16
18
|
@files = files
|
|
17
19
|
@stderr = stderr
|
|
20
|
+
@rails_inference = rails_inference
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def call
|
|
24
|
+
@warnings = []
|
|
21
25
|
constants = constant_ownership
|
|
22
26
|
references = resolved_reference_sets(@files, constants)
|
|
23
27
|
reverse_constants = constants.invert
|
|
@@ -45,7 +49,7 @@ module StudFinder
|
|
|
45
49
|
[file, { dependents: dependents[file].uniq, dependencies: dependencies[file].uniq }]
|
|
46
50
|
end
|
|
47
51
|
|
|
48
|
-
Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges)
|
|
52
|
+
Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges, warnings: @warnings)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
private
|
|
@@ -90,6 +94,10 @@ module StudFinder
|
|
|
90
94
|
constant = [root, tail].join('::')
|
|
91
95
|
return constant if known_constants.include?(constant)
|
|
92
96
|
|
|
97
|
+
# Ruby locks qualified lookup into the first matching namespace root. If
|
|
98
|
+
# the tail is absent, it raises instead of falling back to an outer
|
|
99
|
+
# constant; incomplete ownership can rarely undercount, but avoids
|
|
100
|
+
# non-Ruby overcounts.
|
|
93
101
|
return nil
|
|
94
102
|
end
|
|
95
103
|
|
|
@@ -131,20 +139,40 @@ module StudFinder
|
|
|
131
139
|
|
|
132
140
|
candidates = reference_candidates(node)
|
|
133
141
|
references << candidates if candidates.any?
|
|
142
|
+
end.merge(rails_reference_candidates(ast))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def rails_reference_candidates(ast)
|
|
146
|
+
return Set.new unless @rails_inference
|
|
147
|
+
|
|
148
|
+
RailsInference.new(ast).call.each_with_object(Set.new) do |inference, references|
|
|
149
|
+
candidates = reference_candidate(inference.node, inference.name, inference.absolute)
|
|
150
|
+
references << candidates if candidates.any?
|
|
134
151
|
end
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
code = 'fan_in_rails_inference_failed'
|
|
154
|
+
@warnings << code unless @warnings.include?(code)
|
|
155
|
+
@stderr.puts "Warning: #{code}: #{e.class}: #{e.message}"
|
|
156
|
+
Set.new
|
|
135
157
|
end
|
|
136
158
|
|
|
137
159
|
def reference_candidates(node)
|
|
138
160
|
name = constant_name(node)
|
|
139
161
|
return [] unless name
|
|
140
162
|
|
|
163
|
+
reference_candidate(node, name, absolute_const_reference?(node))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def reference_candidate(node, name, absolute)
|
|
141
167
|
namespace = lexical_namespace(node)
|
|
142
|
-
|
|
168
|
+
candidates = name.include?('::') && !absolute ? [] : constant_candidates(namespace, name, absolute)
|
|
143
169
|
reference_candidate_cache[[namespace, name, absolute]] ||=
|
|
144
170
|
ReferenceCandidate.new(namespace: namespace, name: name, absolute: absolute,
|
|
145
|
-
candidates:
|
|
171
|
+
candidates: candidates)
|
|
146
172
|
rescue StandardError => e
|
|
147
|
-
|
|
173
|
+
code = 'fan_in_reference_resolution_failed'
|
|
174
|
+
@warnings << code unless @warnings.include?(code)
|
|
175
|
+
@stderr.puts "Warning: #{code}: #{e.class}: #{e.message}"
|
|
148
176
|
[]
|
|
149
177
|
end
|
|
150
178
|
|
|
@@ -240,4 +268,5 @@ module StudFinder
|
|
|
240
268
|
root == 'app' ? remaining[1..] : remaining
|
|
241
269
|
end
|
|
242
270
|
end
|
|
271
|
+
# rubocop:enable Metrics/ClassLength
|
|
243
272
|
end
|
|
@@ -14,6 +14,7 @@ module StudFinder
|
|
|
14
14
|
'**/*.min.js',
|
|
15
15
|
'tmp/**',
|
|
16
16
|
'log/**',
|
|
17
|
+
'coverage/**',
|
|
17
18
|
'spec/**',
|
|
18
19
|
'test/**',
|
|
19
20
|
'__tests__/**',
|
|
@@ -145,7 +146,7 @@ module StudFinder
|
|
|
145
146
|
return line.match?(/\A\s*#\s*This file is auto-generated/i)
|
|
146
147
|
end
|
|
147
148
|
false
|
|
148
|
-
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
149
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, ArgumentError
|
|
149
150
|
false
|
|
150
151
|
end
|
|
151
152
|
end
|
|
@@ -11,6 +11,10 @@ module StudFinder
|
|
|
11
11
|
|
|
12
12
|
TOOL_MISSING = 'js_tools_missing'
|
|
13
13
|
TIMEOUT = 'js_depcruise_timeout'
|
|
14
|
+
DEPCRUISE_NO_CONFIG = 'js_depcruise_no_config'
|
|
15
|
+
DEPCRUISE_FAILED = 'js_depcruise_failed'
|
|
16
|
+
|
|
17
|
+
FALLBACK_SUCCESS_MESSAGE = 'configured depcruise failed; retried with --no-config; fan_in may be undercounted'
|
|
14
18
|
|
|
15
19
|
def initialize(repo_path:, files:, js_timeout: 60, stderr: $stderr)
|
|
16
20
|
@repo_path = File.expand_path(repo_path)
|
|
@@ -25,16 +29,17 @@ module StudFinder
|
|
|
25
29
|
depcruise = depcruise_binary
|
|
26
30
|
return missing_tools unless depcruise
|
|
27
31
|
|
|
28
|
-
stdout,
|
|
29
|
-
return
|
|
32
|
+
stdout, stderr, status, warnings = run_depcruise(depcruise)
|
|
33
|
+
return depcruise_failed(stderr) unless status.success?
|
|
30
34
|
|
|
31
35
|
counts, fan_out_counts, edges = parse(stdout)
|
|
32
|
-
|
|
36
|
+
warnings.each { |warning| warn(warning.fetch(:code), warning.fetch(:message)) }
|
|
37
|
+
Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges, warnings: warnings)
|
|
33
38
|
rescue Timeout::Error
|
|
34
39
|
warn(TIMEOUT)
|
|
35
40
|
Result.new(counts: zero_counts, fan_out_counts: zero_counts, edges: empty_edges, warnings: [TIMEOUT])
|
|
36
|
-
rescue JSON::ParserError, KeyError, TypeError
|
|
37
|
-
|
|
41
|
+
rescue JSON::ParserError, KeyError, TypeError => e
|
|
42
|
+
depcruise_failed("malformed dependency-cruiser JSON: #{e.message}")
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
private
|
|
@@ -60,7 +65,19 @@ module StudFinder
|
|
|
60
65
|
|
|
61
66
|
def run_depcruise(depcruise)
|
|
62
67
|
Timeout.timeout(@js_timeout) do
|
|
63
|
-
Open3.capture3(depcruise, '--output-type', 'json', '.',
|
|
68
|
+
primary_stdout, primary_stderr, primary_status = Open3.capture3(depcruise, '--output-type', 'json', '.',
|
|
69
|
+
chdir: @repo_path)
|
|
70
|
+
return [primary_stdout, primary_stderr, primary_status, []] if primary_status.success?
|
|
71
|
+
|
|
72
|
+
retry_stdout, retry_stderr, retry_status = Open3.capture3(
|
|
73
|
+
depcruise, '--output-type', 'json', '.', '--no-config', chdir: @repo_path
|
|
74
|
+
)
|
|
75
|
+
if retry_status.success?
|
|
76
|
+
[retry_stdout, retry_stderr, retry_status,
|
|
77
|
+
[{ code: DEPCRUISE_NO_CONFIG, message: fallback_success_message(primary_stderr) }]]
|
|
78
|
+
else
|
|
79
|
+
[primary_stdout, primary_stderr, primary_status, []]
|
|
80
|
+
end
|
|
64
81
|
end
|
|
65
82
|
end
|
|
66
83
|
|
|
@@ -106,6 +123,29 @@ module StudFinder
|
|
|
106
123
|
Result.new(counts: zero_counts, fan_out_counts: zero_counts, edges: empty_edges, warnings: [TOOL_MISSING])
|
|
107
124
|
end
|
|
108
125
|
|
|
126
|
+
def depcruise_failed(stderr)
|
|
127
|
+
detail = first_line(stderr)
|
|
128
|
+
message = detail.empty? ? nil : detail
|
|
129
|
+
warn(DEPCRUISE_FAILED, message)
|
|
130
|
+
Result.new(
|
|
131
|
+
counts: zero_counts,
|
|
132
|
+
fan_out_counts: zero_counts,
|
|
133
|
+
edges: empty_edges,
|
|
134
|
+
warnings: [{ code: DEPCRUISE_FAILED, message: message }]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fallback_success_message(primary_stderr)
|
|
139
|
+
detail = first_line(primary_stderr)
|
|
140
|
+
return FALLBACK_SUCCESS_MESSAGE if detail.empty?
|
|
141
|
+
|
|
142
|
+
"#{FALLBACK_SUCCESS_MESSAGE}: #{detail}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def first_line(text)
|
|
146
|
+
text.to_s.lines.first.to_s.strip
|
|
147
|
+
end
|
|
148
|
+
|
|
109
149
|
def zero_counts
|
|
110
150
|
@files.to_h { |file| [file, 0] }
|
|
111
151
|
end
|
|
@@ -114,8 +154,9 @@ module StudFinder
|
|
|
114
154
|
@files.to_h { |file| [file, { dependents: [], dependencies: [] }] }
|
|
115
155
|
end
|
|
116
156
|
|
|
117
|
-
def warn(code)
|
|
118
|
-
|
|
157
|
+
def warn(code, message = nil)
|
|
158
|
+
suffix = message.to_s.empty? ? '' : ": #{message}"
|
|
159
|
+
@stderr.puts "Warning: #{code}#{suffix}"
|
|
119
160
|
end
|
|
120
161
|
end
|
|
121
162
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StudFinder
|
|
4
|
+
class LocCounter
|
|
5
|
+
def initialize(repo_path:, files:)
|
|
6
|
+
@repo_path = File.expand_path(repo_path)
|
|
7
|
+
@files = files
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
@files.to_h { |file| [file, non_blank_lines(file)] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def non_blank_lines(file)
|
|
17
|
+
File.foreach(File.join(@repo_path, file)).count { |line| !line.strip.empty? }
|
|
18
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError, ArgumentError
|
|
19
|
+
0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|