rubylens 0.1.0 → 0.2.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -39
  3. data/assets/runtime/report.js +1607 -251
  4. data/assets/shells/report.html +9 -7
  5. data/assets/shells/showcase.html +1 -0
  6. data/assets/styles/report.css +51 -25
  7. data/assets/styles/showcase.css +3 -0
  8. data/lib/rubylens/art_model_builder.rb +214 -72
  9. data/lib/rubylens/artifact_marker.rb +17 -0
  10. data/lib/rubylens/atomic_output.rb +27 -0
  11. data/lib/rubylens/cli.rb +53 -36
  12. data/lib/rubylens/clip/chrome_page.rb +183 -0
  13. data/lib/rubylens/clip/deadline_io.rb +28 -0
  14. data/lib/rubylens/clip/renderer.rb +113 -0
  15. data/lib/rubylens/clip/toolchain.rb +82 -0
  16. data/lib/rubylens/clip/web_socket_channel.rb +116 -0
  17. data/lib/rubylens/clip_generator.rb +102 -0
  18. data/lib/rubylens/default_output.rb +24 -0
  19. data/lib/rubylens/generator.rb +9 -10
  20. data/lib/rubylens/git_repository.rb +13 -7
  21. data/lib/rubylens/index/constant_reference_collector.rb +150 -0
  22. data/lib/rubylens/index/declaration_collector.rb +288 -0
  23. data/lib/rubylens/index/git_package_source.rb +202 -0
  24. data/lib/rubylens/index/location_index.rb +103 -0
  25. data/lib/rubylens/index/manifest.rb +62 -157
  26. data/lib/rubylens/index/rspec_extractor.rb +12 -10
  27. data/lib/rubylens/index/rubydex_adapter.rb +111 -301
  28. data/lib/rubylens/index/source_path.rb +12 -11
  29. data/lib/rubylens/model/dependency_aggregation.rb +11 -7
  30. data/lib/rubylens/morphology_classifier.rb +52 -12
  31. data/lib/rubylens/report_writer.rb +5 -20
  32. data/lib/rubylens/showcase_generator.rb +5 -9
  33. data/lib/rubylens/showcase_model.rb +12 -14
  34. data/lib/rubylens/showcase_writer.rb +0 -6
  35. data/lib/rubylens/version.rb +1 -1
  36. data/lib/rubylens.rb +7 -0
  37. metadata +16 -3
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "pathname"
5
+ require "set"
6
+
7
+ module RubyLens
8
+ module Index
9
+ # Resolves a git-sourced lockfile entry into the exact files RubyLens may index.
10
+ #
11
+ # Git checkouts carry more authority than RubyGems packages: the gemspec is
12
+ # code from the checkout that names its own require paths, and the tree may
13
+ # hold symlinks pointing anywhere on disk. Every path here is therefore
14
+ # resolved and re-checked against the canonical package root before it
15
+ # becomes indexable, and any breach ends the package rather than narrowing
16
+ # it, so a partially trusted tree can never contribute files.
17
+ class GitPackageSource
18
+ UnsafeRequirePath = Class.new(StandardError)
19
+ UnsafePackageFile = Class.new(StandardError)
20
+
21
+ # Logical roots come from the gemspec and may be symlinked; the canonical
22
+ # root is the resolved directory every indexable file must sit inside.
23
+ PackagePaths = Data.define(
24
+ :logical_root, #: Pathname
25
+ :canonical_root, #: Pathname
26
+ :logical_canonical_root, #: Pathname
27
+ )
28
+ Resolution = Data.define(
29
+ :root, #: Pathname
30
+ :files, #: Array[String]
31
+ )
32
+
33
+ # Nix stores gems in immutable, content-addressed store objects, so a
34
+ # checkout symlinked into one is trustworthy even though it resolves
35
+ # outside the bundle directory.
36
+ class NixStoreProvider
37
+ STORE_ROOT = Pathname("/nix/store").freeze
38
+ STORE_OBJECT_PATTERN = /\A[0123456789abcdfghijklmnpqrsvwxyz]{32}-.+\z/
39
+
40
+ #: (Pathname | String) -> bool
41
+ def trusted?(path)
42
+ path = Pathname(path).expand_path
43
+ return false unless Paths.inside?(path, STORE_ROOT)
44
+
45
+ first_component = path.relative_path_from(STORE_ROOT).each_filename.first
46
+ return false unless first_component
47
+
48
+ STORE_OBJECT_PATTERN.match?(first_component)
49
+ end
50
+ end
51
+
52
+ #: (lockfile: Pathname) -> void
53
+ def initialize(lockfile:)
54
+ @lockfile = lockfile
55
+ @nix_store_provider = NixStoreProvider.new
56
+ @specification_indexes = {}.compare_by_identity
57
+ end
58
+
59
+ # Returns a Resolution, or a Symbol naming why the package was skipped.
60
+ # Callers own how a skip is reported, so every reason surfaces through the
61
+ # same warning vocabulary as the other package sources.
62
+ #
63
+ #: (Bundler::LazySpecification) -> (Resolution | Symbol)
64
+ def resolve(locked)
65
+ source = locked.source
66
+ return :local_only_required if source.allow_git_ops?
67
+
68
+ checkout = checkout_path(source)
69
+ return :checkout_unavailable unless checkout.directory?
70
+
71
+ index = (@specification_indexes[source] ||= specification_index(source, checkout))
72
+ specification = index.search(locked).first
73
+ return :specification_unavailable unless specification
74
+
75
+ paths = package_paths(specification, checkout)
76
+ return :unsafe_specification_root unless paths
77
+
78
+ Resolution.new(
79
+ root: paths.canonical_root,
80
+ files: indexable_files(paths, specification.require_paths).freeze,
81
+ )
82
+ rescue UnsafeRequirePath
83
+ :unsafe_require_paths
84
+ rescue UnsafePackageFile
85
+ :unsafe_package_files
86
+ rescue Bundler::BundlerError, Gem::Exception
87
+ :specification_unreadable
88
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
89
+ :checkout_unavailable
90
+ end
91
+
92
+ private
93
+
94
+ #: (Bundler::Source::Git) -> Pathname
95
+ def checkout_path(source)
96
+ bundle_root = @lockfile.dirname
97
+ app_config = ENV["BUNDLE_APP_CONFIG"]
98
+ app_config_path = if app_config
99
+ Pathname(app_config).expand_path(bundle_root)
100
+ else
101
+ bundle_root.join(".bundle")
102
+ end
103
+ bundle_path = Pathname(Bundler::Settings.new(app_config_path).path.path).expand_path(bundle_root)
104
+ bundle_path.join("bundler/gems", source.extension_dir_name)
105
+ end
106
+
107
+ #: (Bundler::Source::Git, Pathname) -> Bundler::Index
108
+ def specification_index(source, checkout)
109
+ source.__send__(:set_install_path!, checkout)
110
+ source.specs
111
+ end
112
+
113
+ #: (Gem::Specification, Pathname) -> PackagePaths?
114
+ def package_paths(specification, checkout)
115
+ logical_root = Pathname(specification.full_gem_path).expand_path
116
+ logical_gemspec = Pathname(specification.loaded_from).expand_path
117
+ return unless Paths.inside?(logical_root, checkout)
118
+ return unless logical_gemspec.dirname == logical_root
119
+ return unless logical_gemspec.file? && logical_root.directory?
120
+
121
+ canonical_checkout = checkout.realpath
122
+ resolved_gemspec_root = logical_gemspec.realpath.dirname
123
+ logical_canonical_root = logical_root.realpath
124
+ return unless resolved_gemspec_root.directory?
125
+
126
+ normal_checkout = !checkout.symlink? &&
127
+ Paths.inside?(resolved_gemspec_root, canonical_checkout) &&
128
+ Paths.inside?(logical_canonical_root, canonical_checkout)
129
+ return unless normal_checkout || @nix_store_provider.trusted?(resolved_gemspec_root)
130
+
131
+ PackagePaths.new(
132
+ logical_root: logical_root,
133
+ canonical_root: normal_checkout ? logical_canonical_root : resolved_gemspec_root,
134
+ logical_canonical_root: logical_canonical_root,
135
+ )
136
+ rescue TypeError, ArgumentError
137
+ nil
138
+ end
139
+
140
+ #: (PackagePaths, Array[String]) -> Array[String]
141
+ def indexable_files(paths, require_paths)
142
+ logical_require_paths = require_paths.map do |relative_path|
143
+ raise UnsafeRequirePath unless relative_path.is_a?(String)
144
+
145
+ relative = Pathname(relative_path)
146
+ raise UnsafeRequirePath if relative.absolute? || relative.each_filename.include?("..")
147
+
148
+ candidate = paths.logical_root.join(relative).cleanpath
149
+ raise UnsafeRequirePath unless Paths.inside?(candidate, paths.logical_root)
150
+
151
+ candidate
152
+ rescue ArgumentError, EncodingError
153
+ raise UnsafeRequirePath
154
+ end
155
+
156
+ files = []
157
+ visited_directories = Set.new
158
+ logical_require_paths.each do |path|
159
+ next unless path.exist? || path.symlink?
160
+
161
+ traverse(path, paths, files, visited_directories, Set.new)
162
+ end
163
+ files.uniq.sort
164
+ end
165
+
166
+ # Descends one require path. `active_directories` carries the current
167
+ # branch so a symlink cycle raises instead of recursing forever, while
168
+ # `visited_directories` spans the whole walk so a diamond of symlinks is
169
+ # merely skipped.
170
+ #
171
+ #: (Pathname, PackagePaths, Array[String], Set[Pathname], Set[Pathname]) -> void
172
+ def traverse(path, paths, files, visited_directories, active_directories)
173
+ resolved = path.realpath
174
+ if resolved.file?
175
+ raise UnsafePackageFile unless Paths.inside?(resolved, paths.canonical_root)
176
+
177
+ files << resolved.to_s if INDEXABLE_EXTENSIONS.include?(path.extname)
178
+ return
179
+ end
180
+ return unless resolved.directory?
181
+
182
+ unless Paths.inside?(resolved, paths.logical_canonical_root) || Paths.inside?(resolved, paths.canonical_root)
183
+ raise UnsafePackageFile
184
+ end
185
+ raise UnsafePackageFile if active_directories.include?(resolved)
186
+ return if visited_directories.include?(resolved)
187
+
188
+ active_directories.add(resolved)
189
+ begin
190
+ path.children.sort_by(&:to_s).each do |child|
191
+ traverse(child, paths, files, visited_directories, active_directories)
192
+ end
193
+ visited_directories.add(resolved)
194
+ ensure
195
+ active_directories.delete(resolved)
196
+ end
197
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
198
+ raise UnsafePackageFile
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "source_path"
5
+
6
+ module RubyLens
7
+ module Index
8
+ # Answers the questions the collectors ask about a Rubydex location: which
9
+ # file it names, whether that file is in the workspace, whether it is test
10
+ # or core code, and which package owns it.
11
+ #
12
+ # Rubydex hands out a fresh wrapper and a fresh URI string on every call and
13
+ # a codebase has far more definitions than files, so each answer is memoized
14
+ # by URI. That memoization is why the collectors can afford to ask per
15
+ # definition instead of hoisting the questions themselves.
16
+ class LocationIndex
17
+ # 1 for a file under a test directory, 0 for any other workspace file.
18
+ TEST = 1
19
+ CORE = 0
20
+ TEST_SEGMENTS = %w[test tests spec specs feature features].freeze
21
+
22
+ #: Set[String]
23
+ attr_reader :package_document_paths
24
+
25
+ # The manifest is duck-typed: production passes a Manifest, tests pass
26
+ # stubs answering the same four questions.
27
+ #
28
+ #: (untyped manifest) -> void
29
+ def initialize(manifest)
30
+ @manifest = manifest
31
+ @path_by_uri = {}
32
+ @workspace_by_uri = {}
33
+ @scope_by_uri = {}
34
+ @package_index_by_uri = {}
35
+ @package_document_paths = Set.new
36
+ end
37
+
38
+ # One sweep over the indexed documents resolves each URI once, recording
39
+ # which of them belong to audited packages and returning the
40
+ # [document, path] pairs so callers need not re-enumerate or re-parse.
41
+ #
42
+ #: (Rubydex::Graph) -> Array[[Rubydex::Document, String]]
43
+ def resolve_documents(graph)
44
+ audited = @manifest.packages.flat_map(&:files).to_set
45
+ documents_with_paths = []
46
+ graph.documents.each do |document|
47
+ path = path_for(document.uri)
48
+ next unless path
49
+
50
+ documents_with_paths << [document, path]
51
+ @package_document_paths << path if audited.include?(path)
52
+ end
53
+ documents_with_paths
54
+ end
55
+
56
+ #: (String) -> String?
57
+ def path_for(uri)
58
+ @path_by_uri.fetch(uri) do
59
+ @path_by_uri[uri] = SourcePath.from_file_uri(uri)
60
+ end
61
+ end
62
+
63
+ #: (Rubydex::Location) -> bool
64
+ def workspace?(location)
65
+ uri = location.uri
66
+ @workspace_by_uri.fetch(uri) do
67
+ path = path_for(uri)
68
+ @workspace_by_uri[uri] = path ? @manifest.workspace_path?(path) : false
69
+ end
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ # TEST, CORE, or nil when the URI has no workspace-relative path.
75
+ #
76
+ #: (String) -> Integer?
77
+ def scope_for(uri)
78
+ @scope_by_uri.fetch(uri) do
79
+ relative = @manifest.relative_workspace_path(path_for(uri))
80
+ @scope_by_uri[uri] =
81
+ if relative
82
+ relative.split(File::SEPARATOR).any? { |segment| TEST_SEGMENTS.include?(segment) } ? TEST : CORE
83
+ end
84
+ end
85
+ end
86
+
87
+ # Package attribution is deliberately limited to documents Rubydex actually
88
+ # indexed, so a path that merely sits inside a package root cannot claim it.
89
+ #
90
+ #: (Rubydex::Location) -> Integer?
91
+ def package_index_for(location)
92
+ uri = location.uri
93
+ @package_index_by_uri.fetch(uri) do
94
+ path = path_for(uri)
95
+ @package_index_by_uri[uri] =
96
+ if path && @package_document_paths.include?(path)
97
+ @manifest.package_index_for(path)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -2,33 +2,17 @@
2
2
 
3
3
  require "bundler"
4
4
  require "bundler/lockfile_parser"
5
- require "find"
6
5
  require "pathname"
7
6
  require "set"
8
7
  require_relative "../dependency_warning"
8
+ require_relative "git_package_source"
9
9
 
10
10
  module RubyLens
11
11
  module Index
12
12
  class Manifest
13
13
  Package = Data.define(:name, :version, :role, :location, :root, :files)
14
14
  DependencySystem = Data.define(:id, :package_indexes, :label_package_index)
15
- GitPackagePaths = Data.define(:logical_root, :canonical_root, :logical_canonical_root)
16
15
 
17
- class NixStoreProvider
18
- STORE_ROOT = Pathname("/nix/store").freeze
19
- STORE_OBJECT_PATTERN = /\A[0123456789abcdfghijklmnpqrsvwxyz]{32}-.+\z/
20
-
21
- def trusted?(path)
22
- path = Pathname(path).expand_path
23
- return false unless Paths.inside?(path, STORE_ROOT)
24
-
25
- first_component = path.relative_path_from(STORE_ROOT).each_filename.first
26
- first_component && STORE_OBJECT_PATTERN.match?(first_component)
27
- end
28
- end
29
-
30
- UnsafeGitRequirePath = Class.new(StandardError)
31
- UnsafeGitPackageFile = Class.new(StandardError)
32
16
  GIT_SKIP_REASONS = DependencyWarning::REASONS
33
17
 
34
18
  attr_reader :root, :files, :workspace_files, :packages, :dependency_systems, :warnings, :dependency_warnings
@@ -40,7 +24,7 @@ module RubyLens
40
24
  def initialize(root:, lockfile: nil)
41
25
  @root = Pathname(root).expand_path.realpath
42
26
  @lockfile = Pathname(lockfile || @root.join("Gemfile.lock")).expand_path
43
- @nix_store_provider = NixStoreProvider.new
27
+ @git_package_source = GitPackageSource.new(lockfile: @lockfile)
44
28
  @warnings = []
45
29
  @dependency_warnings = []
46
30
  @packages = []
@@ -49,7 +33,7 @@ module RubyLens
49
33
  @package_index_by_file = {}
50
34
  @package_index_cache = {}
51
35
  @relative_workspace_path_cache = {}
52
- @git_spec_indexes = {}.compare_by_identity
36
+ @workspace_path_cache = {}
53
37
  end
54
38
 
55
39
  def build
@@ -70,23 +54,27 @@ module RubyLens
70
54
 
71
55
  def package_index_for(path)
72
56
  path = path.to_s
73
- return @package_index_by_file[path] if @package_index_by_file.key?(path)
74
- return @package_index_cache[path] if @package_index_cache.key?(path)
75
-
76
- @package_index_cache[path] = uncached_package_index_for(path)
57
+ @package_index_by_file.fetch(path) do
58
+ @package_index_cache.fetch(path) do
59
+ @package_index_cache[path] = uncached_package_index_for(path)
60
+ end
61
+ end
77
62
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
78
63
  nil
79
64
  end
80
65
 
81
66
  def workspace_path?(path)
82
- Paths.inside?(path, @root)
67
+ path = path.to_s
68
+ @workspace_path_cache.fetch(path) do
69
+ @workspace_path_cache[path] = Paths.inside?(path, @root)
70
+ end
83
71
  end
84
72
 
85
73
  def relative_workspace_path(path)
86
74
  path = path.to_s
87
- return @relative_workspace_path_cache[path] if @relative_workspace_path_cache.key?(path)
88
-
89
- @relative_workspace_path_cache[path] = uncached_relative_workspace_path(path)
75
+ @relative_workspace_path_cache.fetch(path) do
76
+ @relative_workspace_path_cache[path] = uncached_relative_workspace_path(path)
77
+ end
90
78
  end
91
79
 
92
80
  private
@@ -188,134 +176,17 @@ module RubyLens
188
176
  end
189
177
 
190
178
  def git_package(locked, role)
191
- source = locked.source
192
- return skip_git_dependency(locked, :local_only_required) if source.allow_git_ops?
193
-
194
- checkout = git_checkout_path(source)
195
- return skip_git_dependency(locked, :checkout_unavailable) unless checkout.directory?
196
-
197
- specification = (@git_spec_indexes[source] ||= git_specifications(source, checkout)).search(locked).first
198
- return skip_git_dependency(locked, :specification_unavailable) unless specification
199
-
200
- paths = git_package_paths(specification, checkout)
201
- return skip_git_dependency(locked, :unsafe_specification_root) unless paths
202
-
203
- files = git_indexable_files(paths, specification.require_paths)
179
+ resolution = @git_package_source.resolve(locked)
180
+ return skip_git_dependency(locked, resolution) if resolution.is_a?(Symbol)
204
181
 
205
182
  Package.new(
206
183
  name: locked.name,
207
184
  version: locked.version.to_s,
208
185
  role: role,
209
186
  location: "external",
210
- root: paths.canonical_root,
211
- files: files.freeze,
187
+ root: resolution.root,
188
+ files: resolution.files,
212
189
  )
213
- rescue UnsafeGitRequirePath
214
- skip_git_dependency(locked, :unsafe_require_paths)
215
- rescue UnsafeGitPackageFile
216
- skip_git_dependency(locked, :unsafe_package_files)
217
- rescue Bundler::BundlerError, Gem::Exception
218
- skip_git_dependency(locked, :specification_unreadable)
219
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
220
- skip_git_dependency(locked, :checkout_unavailable)
221
- end
222
-
223
- def git_checkout_path(source)
224
- bundle_root = @lockfile.dirname
225
- app_config = ENV["BUNDLE_APP_CONFIG"]
226
- app_config_path = if app_config
227
- Pathname(app_config).expand_path(bundle_root)
228
- else
229
- bundle_root.join(".bundle")
230
- end
231
- bundle_path = Pathname(Bundler::Settings.new(app_config_path).path.path).expand_path(bundle_root)
232
- bundle_path.join("bundler/gems", source.extension_dir_name)
233
- end
234
-
235
- def git_specifications(source, checkout)
236
- source.__send__(:set_install_path!, checkout)
237
- source.specs
238
- end
239
-
240
- def git_package_paths(specification, checkout)
241
- logical_root = Pathname(specification.full_gem_path).expand_path
242
- logical_gemspec = Pathname(specification.loaded_from).expand_path
243
- return unless Paths.inside?(logical_root, checkout)
244
- return unless logical_gemspec.dirname == logical_root
245
- return unless logical_gemspec.file? && logical_root.directory?
246
-
247
- canonical_checkout = checkout.realpath
248
- resolved_gemspec_root = logical_gemspec.realpath.dirname
249
- logical_canonical_root = logical_root.realpath
250
- return unless resolved_gemspec_root.directory?
251
- normal_checkout = !checkout.symlink? &&
252
- Paths.inside?(resolved_gemspec_root, canonical_checkout) &&
253
- Paths.inside?(logical_canonical_root, canonical_checkout)
254
- return unless normal_checkout || @nix_store_provider.trusted?(resolved_gemspec_root)
255
-
256
- canonical_root = normal_checkout ? logical_canonical_root : resolved_gemspec_root
257
-
258
- GitPackagePaths.new(
259
- logical_root: logical_root,
260
- canonical_root: canonical_root,
261
- logical_canonical_root: logical_canonical_root,
262
- )
263
- rescue TypeError, ArgumentError
264
- nil
265
- end
266
-
267
- def git_indexable_files(paths, require_paths)
268
- logical_require_paths = require_paths.map do |relative_path|
269
- raise UnsafeGitRequirePath unless relative_path.is_a?(String)
270
-
271
- relative = Pathname(relative_path)
272
- raise UnsafeGitRequirePath if relative.absolute? || relative.each_filename.include?("..")
273
-
274
- candidate = paths.logical_root.join(relative).cleanpath
275
- raise UnsafeGitRequirePath unless Paths.inside?(candidate, paths.logical_root)
276
-
277
- candidate
278
- rescue ArgumentError, EncodingError
279
- raise UnsafeGitRequirePath
280
- end
281
-
282
- files = []
283
- visited_directories = Set.new
284
- logical_require_paths.each do |path|
285
- next unless path.exist? || path.symlink?
286
-
287
- traverse_git_package_path(path, paths, files, visited_directories, Set.new)
288
- end
289
- files.uniq.sort
290
- end
291
-
292
- def traverse_git_package_path(path, paths, files, visited_directories, active_directories)
293
- resolved = path.realpath
294
- if resolved.file?
295
- raise UnsafeGitPackageFile unless Paths.inside?(resolved, paths.canonical_root)
296
-
297
- files << resolved.to_s if indexable?(path)
298
- return
299
- end
300
- return unless resolved.directory?
301
-
302
- unless Paths.inside?(resolved, paths.logical_canonical_root) || Paths.inside?(resolved, paths.canonical_root)
303
- raise UnsafeGitPackageFile
304
- end
305
- raise UnsafeGitPackageFile if active_directories.include?(resolved)
306
- return if visited_directories.include?(resolved)
307
-
308
- active_directories.add(resolved)
309
- begin
310
- path.children.sort_by(&:to_s).each do |child|
311
- traverse_git_package_path(child, paths, files, visited_directories, active_directories)
312
- end
313
- visited_directories.add(resolved)
314
- ensure
315
- active_directories.delete(resolved)
316
- end
317
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
318
- raise UnsafeGitPackageFile
319
190
  end
320
191
 
321
192
  def skip_git_dependency(locked, reason_code)
@@ -428,20 +299,54 @@ module RubyLens
428
299
  end.flat_map { |path| enumerate(path, root) }.uniq.sort
429
300
  end
430
301
 
302
+ # Walks an already-canonical directory (the caller resolved and containment-checked
303
+ # it) without re-resolving every entry: descending only non-symlink directories
304
+ # keeps each joined path canonical, so regular files join the list with a cheap
305
+ # prefix containment check and only symlinked entries pay for a realpath. Symlinked
306
+ # directories are never descended, matching Find.find's lstat-based traversal.
431
307
  def enumerate(path, root)
432
308
  return [path.to_s] if path.file? && indexable?(path) && Paths.inside?(path.realpath, root)
433
309
  return [] unless path.directory?
310
+ return [] if path.symlink?
434
311
 
435
312
  files = []
436
- Find.find(path) do |candidate|
437
- candidate = Pathname(candidate)
438
- next if candidate.directory?
439
- next unless indexable?(candidate)
440
-
441
- resolved = candidate.realpath
442
- files << resolved.to_s if Paths.inside?(resolved, root)
443
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
444
- next
313
+ root_prefix = "#{root}#{File::SEPARATOR}"
314
+ start = path.to_s
315
+ # Child names must come back in the starting path's encoding. A package
316
+ # root that is not valid UTF-8 otherwise yields UTF-8 entry names that
317
+ # cannot be joined to it, and the whole build aborts on a tree the
318
+ # Find-based walk indexed fine.
319
+ encoding = start.encoding == Encoding::US_ASCII ? Encoding.find("filesystem") : start.encoding
320
+ pending = [start]
321
+ while (directory = pending.pop)
322
+ entries = begin
323
+ Dir.children(directory, encoding: encoding)
324
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::EINVAL
325
+ next
326
+ end
327
+ entries.each do |entry|
328
+ candidate = File.join(directory, entry)
329
+ stat = begin
330
+ File.lstat(candidate)
331
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::EINVAL
332
+ next
333
+ end
334
+ if stat.symlink?
335
+ next if File.directory?(candidate)
336
+ next unless INDEXABLE_EXTENSIONS.include?(File.extname(entry))
337
+
338
+ begin
339
+ resolved = File.realpath(candidate)
340
+ files << resolved if Paths.inside?(resolved, root)
341
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
342
+ next
343
+ end
344
+ elsif stat.directory?
345
+ pending << candidate
346
+ elsif INDEXABLE_EXTENSIONS.include?(File.extname(entry))
347
+ files << candidate if candidate.start_with?(root_prefix)
348
+ end
349
+ end
445
350
  end
446
351
  files
447
352
  end
@@ -11,28 +11,24 @@ module RubyLens
11
11
  SPEC_SEGMENTS = Set.new(%w[spec specs]).freeze
12
12
  PROXY_NAME_PREFIX = "RSpec example group #"
13
13
 
14
- Group = Data.define(:name, :component)
15
14
  Result = Data.define(:groups, :method_count)
16
15
 
17
- def initialize(graph:, manifest:, package_document_paths: Set.new)
16
+ def initialize(graph:, manifest:, package_document_paths: Set.new, documents_with_paths: nil)
18
17
  @graph = graph
19
18
  @manifest = manifest
20
19
  @package_document_paths = package_document_paths
20
+ @documents_with_paths = documents_with_paths
21
21
  end
22
22
 
23
23
  def call
24
24
  groups = []
25
25
  method_count = 0
26
26
 
27
- spec_documents.each do |document, relative|
27
+ spec_documents.each do |document, _relative|
28
28
  group_count, example_count = reference_counts(document.method_references)
29
29
  method_count += example_count
30
- component = SourcePath.component_for(relative)
31
30
  group_count.times do
32
- groups << Group.new(
33
- format("%s%06d", PROXY_NAME_PREFIX, groups.length + 1),
34
- component,
35
- )
31
+ groups << format("%s%06d", PROXY_NAME_PREFIX, groups.length + 1)
36
32
  end
37
33
  end
38
34
 
@@ -41,10 +37,16 @@ module RubyLens
41
37
 
42
38
  private
43
39
 
40
+ # The adapter passes its already-resolved [document, path] pairs so the
41
+ # documents are not re-enumerated and their URIs not re-parsed here;
42
+ # standalone construction still resolves them from the graph.
44
43
  def spec_documents
45
- @graph.documents.filter_map do |document|
44
+ pairs = @documents_with_paths || @graph.documents.filter_map do |document|
46
45
  path = SourcePath.from_file_uri(document.uri)
47
- next unless path && @manifest.workspace_path?(path)
46
+ [document, path] if path
47
+ end
48
+ pairs.filter_map do |document, path|
49
+ next unless @manifest.workspace_path?(path)
48
50
  next if @package_document_paths.include?(path)
49
51
 
50
52
  relative = @manifest.relative_workspace_path(path)