rubylens 0.2.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.
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+ require_relative "../model/dependency_aggregation"
5
+
6
+ module RubyLens
7
+ module Index
8
+ # Streams Rubydex's declarations once and splits them into the three
9
+ # populations the snapshot needs: workspace namespaces that become scene
10
+ # points, Ruby construct tallies per category, and dependency declaration
11
+ # rows aggregated per package.
12
+ #
13
+ # It is a single pass because Rubydex materializes a new wrapper for every
14
+ # accessor call, so revisiting the stream costs as much as producing it.
15
+ class DeclarationCollector
16
+ # Shapes shared with the other collectors. A site key is a definition's
17
+ # location flattened for comparison and grouping; a definition range is a
18
+ # namespace's span within one file, tagged with the namespace's ordinal.
19
+ #
20
+ # @rbs!
21
+ # type site_key = [String, Integer, Integer, Integer, Integer]
22
+ # type definition_range = [Integer, Integer, Integer, Integer, Integer]
23
+ # type dependency_package = { ruby_counts: Array[Integer], declarations: Array[Array[Integer]] }
24
+
25
+ # A workspace namespace that earned a scene point, with the canonical
26
+ # definition sites that decide where its constant references land.
27
+ Namespace = Data.define(
28
+ :declaration, #: Rubydex::Namespace
29
+ :name, #: String
30
+ :definition_sites, #: Integer
31
+ :scope, #: Integer
32
+ )
33
+
34
+ Result = Data.define(
35
+ :namespaces, #: Array[Namespace]
36
+ :definition_ranges, #: Hash[String, Array[definition_range]]
37
+ :category_stats, #: Hash[String, Array[Integer]]
38
+ :dependency_packages, #: Array[dependency_package]
39
+ :dependency_signal_maxima, #: Array[Integer]
40
+ :dependency_ordinal_by_name, #: Hash[String, Integer]
41
+ )
42
+
43
+ CLASS = 0
44
+ MODULE = 1
45
+ METHOD = 2
46
+ CONSTANT = 3
47
+ OTHER_KIND = 2
48
+ MIXED_SCOPE = 2
49
+
50
+ #: (manifest: untyped, locations: LocationIndex) -> void
51
+ def initialize(manifest:, locations:)
52
+ @manifest = manifest
53
+ @locations = locations
54
+ end
55
+
56
+ #: (Enumerable[Rubydex::Declaration]) -> Result
57
+ def call(declarations)
58
+ namespaces = []
59
+ category_stats = { "core" => Array.new(4, 0), "tests" => Array.new(4, 0) }
60
+ aggregation = Model::DependencyAggregation.new(package_count: @manifest.packages.length)
61
+ definition_ranges = Hash.new { |ranges, uri| ranges[uri] = [] }
62
+ dependency_positions = {}
63
+
64
+ declarations.each do |declaration|
65
+ name = declaration.name
66
+ next unless eligible?(declaration, name)
67
+
68
+ namespace = declaration.is_a?(Rubydex::Namespace)
69
+ construct = construct_index(declaration)
70
+ # Destructured immediately rather than wrapped: this runs once per
71
+ # declaration in the largest loop RubyLens has, and the names below
72
+ # are the only place these five values are ever read.
73
+ workspace_count, tests_only, site_keys, canonical_scope, package_site_keys =
74
+ summarize(declaration, namespace)
75
+
76
+ if site_keys
77
+ site_keys = site_keys.uniq if site_keys.length > 1
78
+ ordinal = namespaces.length
79
+ site_keys.each do |uri, start_line, start_column, end_line, end_column|
80
+ definition_ranges[uri] << [start_line, start_column, end_line, end_column, ordinal]
81
+ end
82
+ namespaces << Namespace.new(
83
+ declaration: declaration,
84
+ name: name,
85
+ definition_sites: site_keys.length,
86
+ scope: canonical_scope,
87
+ )
88
+ end
89
+ if construct && workspace_count.positive?
90
+ category_stats.fetch(tests_only ? "tests" : "core")[construct] += 1
91
+ end
92
+ if package_site_keys
93
+ collect_dependency(
94
+ aggregation, dependency_positions, declaration, name, namespace, construct, package_site_keys
95
+ )
96
+ end
97
+ end
98
+
99
+ Result.new(
100
+ namespaces: namespaces,
101
+ definition_ranges: definition_ranges,
102
+ category_stats: category_stats,
103
+ dependency_packages: aggregation.packages,
104
+ dependency_signal_maxima: aggregation.signal_maxima,
105
+ dependency_ordinal_by_name: global_dependency_ordinals(aggregation.packages, dependency_positions),
106
+ )
107
+ end
108
+
109
+ # Rubydex reports synthetic declarations RubyLens never draws: anonymous
110
+ # namespaces, Todo placeholders standing in for unresolved constants, and
111
+ # singleton classes attached to either.
112
+ #
113
+ #: (Rubydex::Declaration, ?String?) -> bool
114
+ def eligible?(declaration, name = declaration.name)
115
+ return false if name.nil? || name.empty? || name.include?("<anonymous>")
116
+ return false if declaration.is_a?(Rubydex::Todo)
117
+ return true unless declaration.is_a?(Rubydex::SingletonClass)
118
+
119
+ attached = declaration.attached_class
120
+ !attached.is_a?(Rubydex::SingletonClass) && !attached.is_a?(Rubydex::Todo)
121
+ end
122
+
123
+ #: (Rubydex::Declaration) -> Integer?
124
+ def construct_index(declaration)
125
+ case declaration
126
+ when Rubydex::SingletonClass then nil
127
+ when Rubydex::Class then CLASS
128
+ when Rubydex::Module then MODULE
129
+ when Rubydex::Method then METHOD
130
+ when Rubydex::Constant, Rubydex::ConstantAlias then CONSTANT
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ # One pass over a declaration's definitions, so each definition's location
137
+ # and its per-URI workspace, scope, and package answers are read once.
138
+ # Returns [workspace_count, tests_only, canonical_site_keys,
139
+ # canonical_scope, package_site_keys]; the site-key collections stay nil
140
+ # until a definition contributes one, since most declarations contribute
141
+ # neither a canonical namespace site nor a package site.
142
+ #
143
+ #: (Rubydex::Declaration, bool) -> [Integer, bool, Array[site_key]?, Integer, Hash[Integer, Array[site_key]]?]
144
+ def summarize(declaration, namespace)
145
+ workspace_count = 0
146
+ tests_seen = false
147
+ core_seen = false
148
+ canonical_tests_seen = false
149
+ canonical_core_seen = false
150
+ canonical_site_keys = nil
151
+ package_site_keys = nil
152
+
153
+ declaration.definitions.each do |definition|
154
+ location = definition.location
155
+ site_key = nil
156
+ if @locations.workspace?(location)
157
+ workspace_count += 1
158
+ scope = @locations.scope_for(location.uri)
159
+ if scope == LocationIndex::TEST
160
+ tests_seen = true
161
+ elsif scope
162
+ core_seen = true
163
+ end
164
+ if namespace && canonical_definition?(declaration, definition)
165
+ (canonical_site_keys ||= []) << (site_key = location.comparable_values)
166
+ if scope == LocationIndex::TEST
167
+ canonical_tests_seen = true
168
+ elsif scope
169
+ canonical_core_seen = true
170
+ end
171
+ end
172
+ end
173
+ package_index = @locations.package_index_for(location)
174
+ if package_index
175
+ ((package_site_keys ||= {})[package_index] ||= []) << (site_key || location.comparable_values)
176
+ end
177
+ end
178
+
179
+ [
180
+ workspace_count,
181
+ tests_seen && !core_seen,
182
+ canonical_site_keys,
183
+ canonical_scope(canonical_tests_seen, canonical_core_seen),
184
+ package_site_keys,
185
+ ]
186
+ end
187
+
188
+ #: (bool, bool) -> Integer
189
+ def canonical_scope(tests_seen, core_seen)
190
+ return LocationIndex::CORE unless tests_seen
191
+
192
+ core_seen ? MIXED_SCOPE : LocationIndex::TEST
193
+ end
194
+
195
+ # A declaration reopened across packages is attributed to the one holding
196
+ # the most of its definitions, ties going to the earliest package.
197
+ #
198
+ #: (Model::DependencyAggregation, Hash[String, [Integer, Integer]], Rubydex::Declaration, String, bool, Integer?, Hash[Integer, Array[site_key]]) -> void
199
+ def collect_dependency(aggregation, positions, declaration, name, namespace, construct, package_site_keys)
200
+ package_index = nil
201
+ site_keys = nil
202
+ package_site_keys.each do |index, keys|
203
+ if site_keys.nil? || keys.length > site_keys.length ||
204
+ (keys.length == site_keys.length && index < package_index)
205
+ package_index = index
206
+ site_keys = keys
207
+ end
208
+ end
209
+
210
+ sites = site_keys.length > 1 ? site_keys.uniq.length : site_keys.length
211
+ references = length_of(declaration, :references)
212
+ row = [
213
+ namespace_kind(declaration),
214
+ namespace ? [declaration.ancestors.count - 1, 0].max : 0,
215
+ sites,
216
+ [sites - 1, 0].max,
217
+ namespace ? [declaration.descendants.count - 1, 0].max : 0,
218
+ references,
219
+ namespace ? length_of(declaration, :members) : 0,
220
+ ].freeze
221
+ local_index = aggregation.add(package_index: package_index, row: row, construct_index: construct)
222
+ if references.positive? && constant_reference_target?(declaration, namespace)
223
+ positions[name] = [package_index, local_index]
224
+ end
225
+ end
226
+
227
+ # Dependency stars are addressed by a single ordinal spanning every
228
+ # package, so per-package positions are rebased onto that flat sequence.
229
+ #
230
+ #: (Array[dependency_package], Hash[String, [Integer, Integer]]) -> Hash[String, Integer]
231
+ def global_dependency_ordinals(packages, positions)
232
+ offsets = []
233
+ offset = 0
234
+ packages.each do |package|
235
+ offsets << offset
236
+ offset += package.fetch(:declarations).length
237
+ end
238
+ positions.transform_values! do |package_index, local_index|
239
+ offsets.fetch(package_index) + local_index
240
+ end
241
+ positions.freeze
242
+ end
243
+
244
+ #: (Rubydex::Declaration) -> Integer
245
+ def namespace_kind(declaration)
246
+ case declaration
247
+ when Rubydex::Class then CLASS
248
+ when Rubydex::Module then MODULE
249
+ else OTHER_KIND
250
+ end
251
+ end
252
+
253
+ #: (Rubydex::Declaration, bool) -> bool
254
+ def constant_reference_target?(declaration, namespace)
255
+ namespace ||
256
+ declaration.is_a?(Rubydex::Constant) ||
257
+ declaration.is_a?(Rubydex::ConstantAlias)
258
+ end
259
+
260
+ #: (Rubydex::Declaration, Rubydex::Definition) -> bool
261
+ def canonical_definition?(declaration, definition)
262
+ (declaration.is_a?(Rubydex::Class) && definition.is_a?(Rubydex::ClassDefinition)) ||
263
+ (declaration.is_a?(Rubydex::Module) && definition.is_a?(Rubydex::ModuleDefinition))
264
+ end
265
+
266
+ # Rubydex collections expose size on some shapes and only count on others,
267
+ # and this pre-1.0 interface can raise from either. A dependency star that
268
+ # cannot report a signal is still a real declaration, so an unreadable
269
+ # count degrades to zero rather than losing the row.
270
+ #
271
+ #: (untyped, Symbol) -> Integer
272
+ def length_of(object, name)
273
+ records = object.public_send(name)
274
+ size = records.size
275
+ size.nil? ? count_of(records) : size
276
+ rescue StandardError
277
+ count_of(records)
278
+ end
279
+
280
+ #: (untyped) -> Integer
281
+ def count_of(records)
282
+ records ? records.count : 0
283
+ rescue StandardError
284
+ 0
285
+ end
286
+ end
287
+ end
288
+ end
@@ -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