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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "artifact_marker"
4
+ require_relative "atomic_output"
5
+ require_relative "default_output"
6
+ require_relative "showcase_generator"
7
+ require_relative "clip/renderer"
8
+ require_relative "clip/toolchain"
9
+
10
+ module RubyLens
11
+ ClipResult = Data.define(:output_path, :showcase_path, :counts, :warnings) do
12
+ def to_payload = { output: output_path, showcase: showcase_path, counts: counts, warnings: warnings }
13
+ end
14
+
15
+ # Generates a shareable MP4 of a project's showcase. The showcase HTML is
16
+ # written first (it is the render input and stays useful on its own), then
17
+ # headless Chrome and ffmpeg turn it into one seamless 1080p camera loop.
18
+ # The toolchain is checked before any indexing so a missing browser or
19
+ # ffmpeg fails in under a second with install guidance.
20
+ class ClipGenerator
21
+ DEFAULT_CLIP_NAME = "rubylens-clip.mp4"
22
+ # faststart parks the metadata (and so the marker) right behind ftyp, so
23
+ # every clip RubyLens writes matches within this head window.
24
+ MARKER_SCAN_HEAD_BYTES = 512 * 1024
25
+
26
+ def initialize(path: Dir.pwd, output: nil, lockfile: nil, details: false, progress: nil, renderer: nil)
27
+ @path = path
28
+ @output = output
29
+ @lockfile = lockfile
30
+ @details = details
31
+ @progress = progress
32
+ @renderer = renderer
33
+ end
34
+
35
+ def call
36
+ renderer = @renderer || build_renderer
37
+ root = File.realpath(@path)
38
+ output, showcase_output = resolve_outputs(root)
39
+ showcase_result = ShowcaseGenerator.new(
40
+ path: @path, output: showcase_output, lockfile: @lockfile, details: @details,
41
+ ).call
42
+ render_atomically(renderer, showcase_result.output_path, output)
43
+
44
+ ClipResult.new(
45
+ output_path: output,
46
+ showcase_path: showcase_result.output_path,
47
+ counts: showcase_result.counts,
48
+ warnings: showcase_result.warnings,
49
+ )
50
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
51
+ raise Error, error.message
52
+ end
53
+
54
+ private
55
+
56
+ def build_renderer
57
+ Clip::Renderer.new(
58
+ chrome: Clip::Toolchain.chrome_path,
59
+ ffmpeg: Clip::Toolchain.ffmpeg_path,
60
+ progress: @progress,
61
+ )
62
+ end
63
+
64
+ # With no --output, both artifacts use their default names, Git-excluded
65
+ # locally, and an existing default clip is only replaced if it is ours.
66
+ # With --output FILE.mp4, the MP4 is written exactly where requested, like
67
+ # other custom output paths; the derived FILE.html companion was never
68
+ # named by the user, so an unrelated existing file there is refused.
69
+ def resolve_outputs(root)
70
+ if @output.nil?
71
+ default = DefaultOutput.resolve(root: root, name: DEFAULT_CLIP_NAME, description: "clip") do |existing|
72
+ ArtifactMarker.present?(existing, Clip::Renderer::MARKER_COMMENT, head_bytes: MARKER_SCAN_HEAD_BYTES)
73
+ end
74
+ return [default, nil]
75
+ end
76
+
77
+ # Expanded once here so the guard, the writer, and the reported result
78
+ # all refer to the same real paths.
79
+ output = File.expand_path(@output)
80
+ companion = showcase_companion_path(output)
81
+ if File.exist?(companion) && !ArtifactMarker.present?(companion, ShowcaseWriter::MARKER)
82
+ raise Error, "clip companion path #{companion} already exists and is not a RubyLens showcase"
83
+ end
84
+
85
+ [output, companion]
86
+ end
87
+
88
+ def showcase_companion_path(output)
89
+ base = output.sub(/\.mp4\z/i, "")
90
+ base = output if base.empty?
91
+ "#{base}.html"
92
+ end
93
+
94
+ def render_atomically(renderer, showcase_html, output)
95
+ AtomicOutput.replace(output) do |temporary|
96
+ renderer.render(showcase_html: showcase_html, output: temporary)
97
+ rescue Error => error
98
+ raise Error, "#{error.message} (the showcase HTML was still written to #{showcase_html})"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "git_repository"
5
+
6
+ module RubyLens
7
+ # Safety policy shared by every generator's default output path: an existing
8
+ # file is only replaced when the given check recognizes it as our own kind of
9
+ # artifact, and the path plus its temporary-file pattern are added to the
10
+ # repository's local Git excludes.
11
+ module DefaultOutput
12
+ module_function
13
+
14
+ def resolve(root:, name:, description:)
15
+ output = File.join(root, name)
16
+ if File.exist?(output) && !yield(output)
17
+ raise Error, "default #{description} path already exists and is not a RubyLens #{description}"
18
+ end
19
+
20
+ GitRepository.new(root).exclude_local(output, description: description)
21
+ output
22
+ end
23
+ end
24
+ end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "artifact_marker"
4
+ require_relative "default_output"
5
+
3
6
  module RubyLens
4
- Result = Data.define(:output_path, :counts, :warnings)
7
+ Result = Data.define(:output_path, :counts, :warnings) do
8
+ def to_payload = { output: output_path, counts: counts, warnings: warnings }
9
+ end
5
10
 
6
11
  class GenerationPipeline
7
12
  def initialize(root:, lockfile: nil)
@@ -32,17 +37,11 @@ module RubyLens
32
37
 
33
38
  def call
34
39
  root = File.realpath(@path)
35
- report_writer = ReportWriter.new
36
- output = @output
37
- if output.nil?
38
- output = File.join(root, DEFAULT_REPORT_NAME)
39
- if File.exist?(output) && !report_writer.rubylens_report?(output)
40
- raise Error, "default report path already exists and is not a RubyLens report"
41
- end
42
- GitRepository.new(root).exclude_local(output)
40
+ output = @output || DefaultOutput.resolve(root: root, name: DEFAULT_REPORT_NAME, description: "report") do |existing|
41
+ ArtifactMarker.present?(existing, ReportWriter::MARKER)
43
42
  end
44
43
  model, warnings = GenerationPipeline.new(root:, lockfile: @lockfile).call
45
- output_path = report_writer.write(model, output: output)
44
+ output_path = ReportWriter.new.write(model, output: output)
46
45
 
47
46
  RubyLens::Result.new(output_path: output_path, counts: model.fetch("totals").freeze, warnings:)
48
47
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
@@ -22,19 +22,25 @@ module RubyLens
22
22
  end
23
23
 
24
24
  def selected_files
25
- output, status = capture("ls-files", "-z", "--cached", "--others", "--exclude-standard")
25
+ arguments = ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]
26
+ unless @target_root == @git_root
27
+ relative_target = @target_root.relative_path_from(@git_root)
28
+ arguments.concat(["--", ":(literal)#{relative_target}"])
29
+ end
30
+ output, status = capture(*arguments)
26
31
  raise GitError, "failed to enumerate tracked and unignored files" unless status.success?
27
32
 
33
+ git_root = @git_root.to_s
34
+ target_root = @target_root.to_s
28
35
  output.split("\0").filter_map do |relative_to_git|
29
36
  next unless INDEXABLE_EXTENSIONS.include?(File.extname(relative_to_git))
30
37
 
31
- absolute = @git_root.join(relative_to_git).cleanpath
32
- next unless Paths.inside?(absolute, @target_root)
33
- next unless absolute.file?
34
- resolved = absolute.realpath
35
- next unless Paths.inside?(resolved, @target_root.realpath)
38
+ absolute = File.join(git_root, relative_to_git)
39
+ next unless File.file?(absolute)
40
+ resolved = File.realpath(absolute)
41
+ next unless Paths.inside?(resolved, target_root)
36
42
 
37
- resolved.to_s
43
+ resolved
38
44
  rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
39
45
  nil
40
46
  end.sort
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubydex"
4
+ require "set"
5
+
6
+ module RubyLens
7
+ module Index
8
+ # Turns Rubydex's resolved constant references into two different things:
9
+ # the complete inbound reference count for every workspace namespace, and a
10
+ # bounded sample of directed links the renderer animates as travel.
11
+ #
12
+ # The two have deliberately different contracts. Counts are exact. Links
13
+ # stop at the limits below and are never ranked, so they are a visual
14
+ # sample and must not be read as a complete relationship or call graph.
15
+ class ConstantReferenceCollector
16
+ LINK_LIMIT = 1_024
17
+ ATTRIBUTION_LIMIT = LINK_LIMIT * 2
18
+
19
+ # `inbound_counts` is keyed by namespace ordinal, absent meaning zero;
20
+ # each link is [referring namespace ordinal, referenced endpoint ordinal].
21
+ Summary = Data.define(
22
+ :inbound_counts, #: Hash[Integer, Integer]
23
+ :links, #: Array[[Integer, Integer]]
24
+ )
25
+
26
+ #: (locations: LocationIndex) -> void
27
+ def initialize(locations:)
28
+ @locations = locations
29
+ end
30
+
31
+ # `namespaces` are the collector's workspace namespaces in ordinal order;
32
+ # `ordinal_by_name` and `dependency_ordinal_by_name` address link
33
+ # endpoints, with dependency ordinals offset past the namespace block.
34
+ #
35
+ #: (graph: Rubydex::Graph, namespaces: Array[DeclarationCollector::Namespace], definition_ranges: Hash[String, Array[DeclarationCollector::definition_range]], ordinal_by_name: Hash[String, Integer], dependency_ordinal_by_name: Hash[String, Integer], namespace_count: Integer) -> Summary
36
+ def call(graph:, namespaces:, definition_ranges:, ordinal_by_name:, dependency_ordinal_by_name:, namespace_count:)
37
+ Summary.new(
38
+ inbound_counts: inbound_counts(namespaces),
39
+ links: links(graph, definition_ranges, ordinal_by_name, dependency_ordinal_by_name, namespace_count),
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ # Asking each namespace for its own references is bounded by the
46
+ # namespaces actually drawn, unlike scanning the global reference stream.
47
+ #
48
+ #: (Array[DeclarationCollector::Namespace]) -> Hash[Integer, Integer]
49
+ def inbound_counts(namespaces)
50
+ counts = Hash.new(0)
51
+ namespaces.each_with_index do |namespace, index|
52
+ namespace.declaration.references.each do |reference|
53
+ location = begin
54
+ reference.location
55
+ rescue StandardError
56
+ next
57
+ end
58
+ counts[index] += 1 if @locations.workspace?(location)
59
+ end
60
+ end
61
+ counts.freeze
62
+ end
63
+
64
+ #: (Rubydex::Graph, Hash[String, Array[DeclarationCollector::definition_range]], Hash[String, Integer], Hash[String, Integer], Integer) -> Array[[Integer, Integer]]
65
+ def links(graph, definition_ranges, ordinal_by_name, dependency_ordinal_by_name, namespace_count)
66
+ links = []
67
+ retained = Set.new
68
+ attributions = 0
69
+
70
+ graph.constant_references.each do |reference|
71
+ break if links.length >= LINK_LIMIT || attributions >= ATTRIBUTION_LIMIT
72
+ next unless reference.is_a?(Rubydex::ResolvedConstantReference)
73
+
74
+ name = referenced_name(reference)
75
+ next unless name
76
+
77
+ namespace_ordinal = ordinal_by_name[name]
78
+ dependency_ordinal = dependency_ordinal_by_name[name] unless namespace_ordinal
79
+ next unless namespace_ordinal || dependency_ordinal
80
+
81
+ location = begin
82
+ reference.location
83
+ rescue StandardError
84
+ next
85
+ end
86
+ next unless @locations.workspace?(location)
87
+
88
+ attributions += 1
89
+ endpoint = namespace_ordinal || namespace_count + dependency_ordinal
90
+
91
+ values = begin
92
+ location.comparable_values
93
+ rescue StandardError
94
+ next
95
+ end
96
+ origin = enclosing_namespace_ordinal(values, definition_ranges)
97
+ next unless origin
98
+ next if endpoint == origin
99
+
100
+ link = [origin, endpoint]
101
+ next unless retained.add?(link)
102
+
103
+ links << link
104
+ end
105
+
106
+ links.freeze
107
+ end
108
+
109
+ #: (Rubydex::ResolvedConstantReference) -> String?
110
+ def referenced_name(reference)
111
+ reference.declaration.name
112
+ rescue StandardError
113
+ nil
114
+ end
115
+
116
+ # A reference belongs to the innermost namespace definition containing it.
117
+ # Two definitions sharing the exact same span cannot be told apart, so the
118
+ # reference is dropped rather than attributed to an arbitrary one.
119
+ #
120
+ #: (DeclarationCollector::site_key, Hash[String, Array[DeclarationCollector::definition_range]]) -> Integer?
121
+ def enclosing_namespace_ordinal(location_values, ranges_by_uri)
122
+ uri, start_line, start_column, end_line, end_column = location_values
123
+ best = nil
124
+ ambiguous = false
125
+ ranges_by_uri.fetch(uri, []).each do |range|
126
+ range_start_line, range_start_column, range_end_line, range_end_column, ordinal = range
127
+ next if compare(range_start_line, range_start_column, start_line, start_column).positive?
128
+ next if compare(range_end_line, range_end_column, end_line, end_column).negative?
129
+
130
+ start_comparison = best ? compare(range_start_line, range_start_column, best[0], best[1]) : 1
131
+ end_comparison = best && start_comparison.zero? ? compare(range_end_line, range_end_column, best[2], best[3]) : 0
132
+ if start_comparison.positive? || (start_comparison.zero? && end_comparison.negative?)
133
+ best = range
134
+ ambiguous = false
135
+ elsif start_comparison.zero? && end_comparison.zero? && ordinal != best[4]
136
+ ambiguous = true
137
+ end
138
+ end
139
+
140
+ ambiguous ? nil : best&.fetch(4)
141
+ end
142
+
143
+ #: (Integer, Integer, Integer, Integer) -> Integer
144
+ def compare(left_line, left_column, right_line, right_column)
145
+ line_comparison = left_line <=> right_line
146
+ line_comparison.zero? ? left_column <=> right_column : line_comparison
147
+ end
148
+ end
149
+ end
150
+ end
@@ -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