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.
@@ -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