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.
@@ -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
- stdout, stderr, status = run_rubocop
32
- raise Error, fatal_message(stderr) if status.exitstatus == 2
33
- raise Error, fatal_message(stderr) unless [0, 1].include?(status.exitstatus)
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
- parse(stdout)
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 run_rubocop
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
- @repo_path
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 = @files.to_h { |file| [file, 0] }
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
@@ -19,7 +19,7 @@ module StudFinder
19
19
  def call
20
20
  reported = parse_report
21
21
  @missing_files = @files.reject { |file| reported.key?(file) }
22
- @files.to_h { |file| [file, reported.fetch(file, 0.0)] }
22
+ reported.slice(*@files)
23
23
  end
24
24
 
25
25
  private
@@ -20,7 +20,7 @@ module StudFinder
20
20
  def call
21
21
  reported = parse_report
22
22
  @missing_files = @files.reject { |file| reported.key?(file) }
23
- @files.to_h { |file| [file, reported.fetch(file, 0.0)] }
23
+ reported.slice(*@files)
24
24
  end
25
25
 
26
26
  private
@@ -21,7 +21,7 @@ module StudFinder
21
21
  def call
22
22
  reported = parse_report
23
23
  @missing_files = @files.reject { |file| reported.key?(file) }
24
- @files.to_h { |file| [file, reported.fetch(file, 0.0)] }
24
+ reported.slice(*@files)
25
25
  end
26
26
 
27
27
  private
@@ -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
@@ -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
@@ -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
- absolute = absolute_const_reference?(node)
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: constant_candidates(namespace, name, absolute))
171
+ candidates: candidates)
146
172
  rescue StandardError => e
147
- @stderr.puts "Warning: fan_in_reference_resolution_failed: #{e.class}: #{e.message}"
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, _stderr, status = run_depcruise(depcruise)
29
- return missing_tools unless status.success?
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
- Result.new(counts: counts, fan_out_counts: fan_out_counts, edges: edges, warnings: [])
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
- missing_tools
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', '.', chdir: @repo_path)
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
- @stderr.puts "Warning: #{code}"
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