rubylens 0.1.0.pre.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,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="generator" content="RubyLens">
7
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; font-src 'none'; object-src 'none'; base-uri 'none'">
8
+ <title>RubyLens · Ruby project</title>
9
+ <style>
10
+ {{REPORT_STYLES}} </style>
11
+ </head>
12
+ <body>
13
+ <canvas id="cosmos" tabindex="0" aria-label="Interactive three-dimensional stellar artwork. Hover class and module stars for Ruby code details or gem clouds for package summaries."></canvas>
14
+ <header class="masthead">
15
+ <p class="eyebrow">RubyLens · local report</p>
16
+ <h1>Ruby project</h1>
17
+ <p class="coverage" id="coverage"></p>
18
+ <p class="status" id="status" hidden></p>
19
+ </header>
20
+ <aside class="panel" id="panel" aria-label="Explore this codebase">
21
+ <div class="panel-header">
22
+ <div><h2>Explore this codebase</h2><p>Show a system, focus its stars, or fly to a Ruby code highlight.</p></div>
23
+ <div class="panel-header-actions"><button type="button" id="panel-toggle" aria-expanded="true" aria-controls="panel-body">Hide</button></div>
24
+ </div>
25
+ <div id="panel-body"><div id="controls"></div>
26
+ <div class="foot">Double-click a gem cloud, press Enter or F on a selected gem marker, or tap that marker again to spread its cloud stars. Escape or Reset view exits. This owner-only report embeds class, module, and gem names for hover details; dependency stars remain anonymous. Source, comments, and paths are excluded. Do not share the HTML unless you intend to disclose codebase structure.</div></div>
27
+ </aside>
28
+ <div class="toolbar"><button type="button" id="motion" aria-pressed="false">Pause drift</button><button type="button" id="pan-mode" aria-label="Pan mode" aria-keyshortcuts="P" aria-pressed="false">Pan</button><button type="button" id="zoom-out" aria-label="Zoom out">−</button><output class="zoom-level" id="zoom-level" aria-label="Zoom level">100%</output><button type="button" id="zoom-in" aria-label="Zoom in">+</button><button type="button" id="view">Reset view</button></div>
29
+ <div class="hint">Double-click gem clouds · drag to orbit · Shift-drag/Pan to move · arrow keys move view · scroll at cursor to zoom</div>
30
+ <aside class="tooltip" id="tooltip" role="status" hidden><p class="tooltip-category" id="tooltip-category"></p><p class="tooltip-name" id="tooltip-name"></p><p class="tooltip-context" id="tooltip-context"></p><dl class="tooltip-metrics" id="tooltip-metrics"></dl></aside>
31
+ <script>
32
+ {{REPORT_RUNTIME}} </script>
33
+ </body>
34
+ </html>
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="generator" content="RubyLens">
7
+ <meta name="rubylens-artifact" content="showcase">
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; font-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'">
9
+ <title>RubyLens · Showcase</title>
10
+ <style>
11
+ {{REPORT_STYLES}} </style>
12
+ </head>
13
+ <body data-rubylens-mode="showcase">
14
+ <main class="showcase-stage" id="showcase-stage">
15
+ <canvas id="cosmos" role="img" aria-label="Autonomous stellar artwork of a Ruby codebase."></canvas>
16
+ <header class="masthead">
17
+ <p class="eyebrow">RubyLens · codebase galaxy</p>
18
+ <h1>Ruby project</h1>
19
+ <dl class="cinema-stats" aria-label="Codebase statistics"><div><dt>Core classes</dt><dd id="cinema-classes">0</dd></div><div><dt>Core modules</dt><dd id="cinema-modules">0</dd></div><div><dt>Core methods</dt><dd id="cinema-methods">0</dd></div><div><dt>Core constants</dt><dd id="cinema-constants">0</dd></div></dl>
20
+ <p class="cinema-secondary" id="cinema-secondary"></p>
21
+ </header>
22
+ </main>
23
+ <script>
24
+ {{REPORT_RUNTIME}} </script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,85 @@
1
+ :root { color-scheme: dark; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
2
+ * { box-sizing: border-box; }
3
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #03040a; color: #f6f2ff; }
4
+ body::before { content: ""; position: fixed; inset: 0; pointer-events: none; background: radial-gradient(circle at 42% 45%, rgba(78, 36, 112, .14), transparent 34%), radial-gradient(circle at 68% 22%, rgba(29, 91, 114, .08), transparent 28%); }
5
+ canvas { position: fixed; inset: 0; width: 100%; height: 100%; cursor: grab; touch-action: none; }
6
+ canvas:active { cursor: grabbing; }
7
+ canvas.is-pan, canvas.is-dragging-pan { cursor: move; }
8
+ canvas.is-pan:active, canvas.is-dragging-pan:active { cursor: grabbing; }
9
+ canvas:focus-visible { outline: 2px solid rgba(216, 200, 255, .85); outline-offset: -4px; }
10
+ .masthead { position: fixed; top: 28px; left: 32px; z-index: 2; pointer-events: none; max-width: 760px; }
11
+ .eyebrow { margin: 0 0 10px; color: #b6a9c9; font-size: 11px; font-weight: 750; letter-spacing: .18em; text-transform: uppercase; }
12
+ h1 { margin: 0; font: 500 clamp(28px, 4vw, 54px)/.95 ui-serif, Georgia, serif; letter-spacing: -.045em; }
13
+ .coverage { margin: 12px 0 0; color: #7f788b; font-size: 10px; }
14
+ .status { display: inline-block; margin: 10px 0 0; border: 1px solid rgba(255, 198, 105, .22); border-radius: 999px; padding: 5px 8px; background: rgba(255, 184, 77, .07); color: #c9a96f; font-size: 10px; }
15
+ .status[hidden] { display: none; }
16
+ .panel { position: fixed; z-index: 3; top: 20px; right: 20px; width: min(348px, calc(100vw - 40px)); max-height: calc(100vh - 40px); overflow: auto; border: 1px solid rgba(255,255,255,.1); border-radius: 18px; background: rgba(9, 9, 18, .82); box-shadow: 0 24px 80px rgba(0,0,0,.4); backdrop-filter: blur(18px); transition: width 180ms cubic-bezier(.16, 1, .3, 1); }
17
+ .panel.is-collapsed { width: 190px; overflow: hidden; }
18
+ .panel-header { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; gap: 16px; align-items: start; padding: 18px 18px 14px; border-bottom: 1px solid rgba(255,255,255,.08); background: rgba(9, 9, 18, .92); }
19
+ .panel.is-collapsed .panel-header { align-items: center; padding-block: 13px; border-bottom: 0; }
20
+ .panel.is-collapsed .panel-header p { display: none; }
21
+ .panel-header-actions { display: flex; flex: none; gap: 6px; }
22
+ .panel h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
23
+ .panel-header p { margin: 4px 0 0; color: #8d8798; font-size: 11px; line-height: 1.4; }
24
+ button { border: 1px solid rgba(255,255,255,.12); border-radius: 9px; padding: 7px 10px; background: rgba(255,255,255,.06); color: #eeeaf4; font: inherit; font-size: 11px; cursor: pointer; }
25
+ button:hover { background: rgba(255,255,255,.1); }
26
+ button:focus-visible { outline: 2px solid rgba(216, 200, 255, .9); outline-offset: 2px; }
27
+ details { border-bottom: 1px solid rgba(255,255,255,.07); }
28
+ summary { display: flex; align-items: center; gap: 10px; padding: 14px 18px; cursor: pointer; list-style: none; }
29
+ summary::-webkit-details-marker { display: none; }
30
+ summary::after { content: "⌄"; margin-left: auto; color: #777080; font-size: 12px; transition: transform 160ms cubic-bezier(.16, 1, .3, 1); }
31
+ details[open] summary::after { transform: rotate(180deg); }
32
+ .swatch { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 14px currentColor; }
33
+ .swatch.core { color: #f15f87; background: currentColor; }
34
+ .swatch.tests { color: #69d7ff; background: currentColor; }
35
+ .swatch.dependencies { color: #ffc86a; background: currentColor; }
36
+ .section-heading { display: grid; gap: 2px; }
37
+ .section-heading strong { color: #eee9f4; font-size: 12px; }
38
+ .section-heading small { color: #837c8d; font-size: 10px; font-weight: 500; }
39
+ .section-note { margin: -4px 0 13px; color: #756e7e; font-size: 9px; line-height: 1.4; }
40
+ .section-body { padding: 0 18px 16px; }
41
+ .section-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
42
+ .ruby-breakdown { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 18px; margin: 0 0 14px; padding: 10px 0; border-block: 1px solid rgba(255,255,255,.08); }
43
+ .ruby-breakdown div { min-width: 0; }
44
+ .ruby-breakdown dt, .ruby-breakdown dd { margin: 0; }
45
+ .ruby-breakdown dt { color: #837c8d; font-size: 9px; }
46
+ .ruby-breakdown dd { margin-top: 2px; color: #f3edf9; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
47
+ .section-actions button[aria-pressed="true"] { border-color: rgba(216,200,255,.36); background: rgba(216,200,255,.14); color: #fff; }
48
+ .visibility { display: inline-flex; align-items: center; gap: 7px; margin-right: auto; color: #b8b1c0; font-size: 11px; cursor: pointer; }
49
+ .visibility input { width: 15px; height: 15px; margin: 0; accent-color: #d8c8ff; }
50
+ .fact-label { margin: 0 0 7px; color: #7f788b; font-size: 10px; }
51
+ .facts { display: grid; gap: 5px; }
52
+ .fact { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; width: 100%; padding: 9px 10px; text-align: left; }
53
+ .fact:hover { border-color: rgba(255,255,255,.2); }
54
+ .fact-title { display: flex; align-items: center; gap: 6px; min-width: 0; color: #a9a2b1; font-size: 10px; }
55
+ .fact-scope { flex: none; border: 1px solid rgba(255,255,255,.1); border-radius: 999px; padding: 2px 5px; color: #817a89; font-size: 8px; font-weight: 650; }
56
+ .fact-value { grid-row: 1 / span 2; grid-column: 2; align-self: center; color: #fffafd; font-size: 12px; font-weight: 750; font-variant-numeric: tabular-nums; }
57
+ .fact-name { overflow: hidden; color: #ece6f2; font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
58
+ .fact[aria-pressed="true"] { border-color: rgba(255,255,255,.28); background: rgba(255,255,255,.12); }
59
+ .foot { padding: 15px 18px 17px; color: #7d7788; font-size: 10px; line-height: 1.55; }
60
+ .toolbar { position: fixed; z-index: 2; left: 32px; bottom: 28px; display: flex; gap: 8px; }
61
+ #pan-mode[aria-pressed="true"] { border-color: rgba(216,200,255,.36); background: rgba(216,200,255,.14); color: #fff; }
62
+ .zoom-level { min-width: 43px; align-content: center; color: #9f98a8; font-size: 10px; font-variant-numeric: tabular-nums; text-align: center; }
63
+ .hint { position: fixed; right: 28px; bottom: 24px; z-index: 2; color: #777181; font-size: 10px; pointer-events: none; }
64
+ .tooltip { position: fixed; z-index: 5; width: min(300px, calc(100vw - 24px)); border: 1px solid rgba(255,255,255,.13); border-radius: 13px; padding: 13px 14px; background: rgba(8, 8, 16, .95); box-shadow: 0 18px 55px rgba(0,0,0,.55); pointer-events: none; }
65
+ .tooltip[hidden] { display: none; }
66
+ .tooltip-category { margin: 0 0 4px; color: #9f96ab; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
67
+ .tooltip-name { margin: 0; overflow-wrap: anywhere; color: #fffafd; font-size: 13px; font-weight: 700; line-height: 1.35; }
68
+ .tooltip-context { margin: 4px 0 0; color: #9f98a8; font-size: 10px; line-height: 1.4; }
69
+ .tooltip-metrics { display: grid; grid-template-columns: 1fr auto; gap: 5px 16px; margin: 11px 0 0; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.08); color: #a9a2b1; font-size: 10px; }
70
+ .tooltip-metrics dt, .tooltip-metrics dd { margin: 0; }
71
+ .tooltip-metrics dd { color: #f3edf9; font-variant-numeric: tabular-nums; text-align: right; }
72
+ @media (max-width: 1080px) and (min-width: 761px) {
73
+ .masthead { max-width: calc(100vw - 420px); }
74
+ h1 { font-size: clamp(28px, 4vw, 42px); }
75
+ }
76
+ @media (max-width: 840px) { .hint { display: none; } }
77
+ @media (max-width: 760px) {
78
+ .masthead { top: 22px; left: 20px; max-width: calc(100vw - 40px); }
79
+ .panel { top: auto; bottom: 14px; right: 14px; left: 14px; width: auto; max-height: 46vh; }
80
+ .panel.is-collapsed { right: 14px; left: auto; width: 190px; }
81
+ .toolbar { left: 20px; bottom: 88px; }
82
+ .panel:not(.is-collapsed) ~ .toolbar { bottom: calc(46vh + 28px); }
83
+ .hint { display: none; }
84
+ }
85
+ @media (prefers-reduced-motion: reduce) { .panel, summary::after { transition: none; } }
@@ -0,0 +1,15 @@
1
+ :root { color-scheme: dark; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
2
+ * { box-sizing: border-box; }
3
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #03040a; color: #f6f2ff; }
4
+ .showcase-stage { position: absolute; overflow: hidden; transform-origin: top left; background: #03040a; }
5
+ .showcase-stage::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle at 42% 45%, rgba(78, 36, 112, .14), transparent 34%), radial-gradient(circle at 68% 22%, rgba(29, 91, 114, .08), transparent 28%); }
6
+ canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
7
+ .masthead { position: absolute; z-index: 2; pointer-events: none; text-shadow: 0 2px 14px #03040a, 0 0 4px #03040a; transform-origin: top left; }
8
+ .eyebrow { margin: 0 0 10px; color: #c2b3d4; font-size: 11px; font-weight: 750; letter-spacing: .18em; text-transform: uppercase; }
9
+ h1 { display: -webkit-box; margin: 0; overflow: hidden; overflow-wrap: anywhere; color: #f6f2ff; font: 500 38.88px/.95 ui-serif, Georgia, serif; letter-spacing: -.045em; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
10
+ .cinema-stats { display: grid; grid-template-columns: repeat(4, minmax(72px, auto)); gap: 10px 24px; margin: 20px 0 0; }
11
+ .cinema-stats div { min-width: 0; }
12
+ .cinema-stats dt, .cinema-stats dd { margin: 0; }
13
+ .cinema-stats dt { color: #8e8799; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
14
+ .cinema-stats dd { margin-top: 3px; color: #f5eff9; font-size: 15px; font-weight: 720; font-variant-numeric: tabular-nums; }
15
+ .cinema-secondary { margin: 14px 0 0; color: #91899b; font-size: 10px; letter-spacing: .025em; }
data/exe/rubylens ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubylens"
5
+
6
+ exit RubyLens::CLI.new.run(ARGV)
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLens
4
+ class ArtModelBuilder
5
+ SIGNAL_FIELDS = %w[ancestorDepth definitionSites reopenings descendants references members].freeze
6
+
7
+ def initialize(seed: 0x51A7_E11A)
8
+ @seed = seed
9
+ end
10
+
11
+ def build(snapshot)
12
+ random = Random.new(@seed)
13
+ namespace_order = (0...snapshot.fetch("namespaces").length).to_a.shuffle(random: random)
14
+ namespaces = namespace_order.map do |index|
15
+ row = snapshot.fetch("namespaces").fetch(index)
16
+ [random.rand(0..0xffff_ffff), *row]
17
+ end
18
+ namespace_names = namespace_order.map { |index| snapshot.fetch("namespace_names").fetch(index) }
19
+ package_order = (0...snapshot.fetch("packages").length).to_a.shuffle(random: random)
20
+ package_index = package_order.each_with_index.to_h
21
+ packages = package_order.map do |old_index|
22
+ package = snapshot.fetch("packages").fetch(old_index)
23
+ declaration_count = package.fetch("declaration_count") { package.fetch("declarations").length }
24
+ [
25
+ random.rand(0..0xffff_ffff),
26
+ package.fetch("role"),
27
+ package.fetch("location"),
28
+ declaration_count,
29
+ *package.fetch("ruby_counts"),
30
+ ]
31
+ end
32
+ package_names = package_order.map { |old_index| snapshot.fetch("packages").fetch(old_index).fetch("name") }
33
+ indexed_dependency_count = snapshot.fetch("packages").sum do |package|
34
+ package.fetch("declaration_count") { package.fetch("declarations").length }
35
+ end
36
+ render_target = [18_000, indexed_dependency_count].min
37
+ dependencies = []
38
+ package_order.each do |old_index|
39
+ package = snapshot.fetch("packages").fetch(old_index)
40
+ declarations = package.fetch("declarations").shuffle(random: random)
41
+ quota = if package.key?("declaration_count")
42
+ declarations.length
43
+ elsif declarations.empty?
44
+ 0
45
+ else
46
+ [declarations.length, [1, declarations.length * render_target / [indexed_dependency_count, 1].max].max].min
47
+ end
48
+ declarations.first(quota).each do |declaration|
49
+ dependencies << [
50
+ random.rand(0..0xffff_ffff),
51
+ package_index.fetch(old_index),
52
+ *declaration.drop(1),
53
+ ]
54
+ end
55
+ end
56
+ {
57
+ "schema" => "rubylens.art.v7",
58
+ "projectName" => snapshot.fetch("project_name"),
59
+ "totals" => {
60
+ "namespaces" => namespaces.length,
61
+ "packages" => packages.length,
62
+ "dependencyStars" => indexed_dependency_count,
63
+ "renderedDependencyStars" => dependencies.length,
64
+ },
65
+ "domains" => signal_domains(namespaces, dependencies, snapshot["dependency_signal_maxima"]),
66
+ "componentCounts" => snapshot.fetch("components"),
67
+ "categoryStats" => snapshot.fetch("category_stats"),
68
+ "namespaceNames" => namespace_names,
69
+ "namespaces" => namespaces,
70
+ "packageNames" => package_names,
71
+ "packages" => packages,
72
+ "dependencyStars" => dependencies,
73
+ "warningCounts" => snapshot.fetch("warning_counts"),
74
+ }
75
+ end
76
+
77
+ private
78
+
79
+ def signal_domains(namespaces, dependencies, dependency_maxima = nil)
80
+ namespace_columns = [4, 5, 6, 7, 8, 9]
81
+ dependency_columns = [2, 3, 4, 5, 6, 7]
82
+ namespace_domains = namespace_columns.map { |column| maximum(namespaces, column) }
83
+ dependency_domains = dependency_maxima || dependency_columns.map { |column| maximum(dependencies, column) }
84
+ SIGNAL_FIELDS.each_with_index.to_h do |field, index|
85
+ [field, [namespace_domains[index], dependency_domains[index]].max]
86
+ end
87
+ end
88
+
89
+ def maximum(rows, column)
90
+ rows.map { |row| row[column].to_i }.max || 0
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module RubyLens
7
+ class CLI
8
+ def initialize(
9
+ stdout: $stdout,
10
+ stderr: $stderr,
11
+ report_generator: RubyLens.method(:generate_report),
12
+ showcase_generator: RubyLens.method(:generate_showcase)
13
+ )
14
+ @stdout = stdout
15
+ @stderr = stderr
16
+ @report_generator = report_generator
17
+ @showcase_generator = showcase_generator
18
+ end
19
+
20
+ def run(arguments)
21
+ arguments = arguments.dup
22
+ command = arguments.shift
23
+ return print_version if %w[-v --version version].include?(command)
24
+ return print_help(0) if command.nil? || %w[-h --help help].include?(command)
25
+ return generate_report(arguments) if command == "report"
26
+ return generate_showcase(arguments) if command == "showcase"
27
+
28
+ @stderr.puts "Unknown command: #{command}"
29
+ print_help(2)
30
+ rescue OptionParser::ParseError, Error, Errno::ENOENT => error
31
+ @stderr.puts "rubylens: #{error.message}"
32
+ 2
33
+ end
34
+
35
+ private
36
+
37
+ def generate_report(arguments)
38
+ generate_html(
39
+ arguments,
40
+ command: "report",
41
+ default_name: "rubylens-report.html",
42
+ generator: @report_generator,
43
+ warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
44
+ )
45
+ end
46
+
47
+ def generate_showcase(arguments)
48
+ generate_html(
49
+ arguments,
50
+ command: "showcase",
51
+ default_name: "rubylens-showcase.html",
52
+ generator: @showcase_generator,
53
+ warning: "RubyLens showcases disclose the project name, aggregate statistics, and visual codebase structure. Share them intentionally.",
54
+ )
55
+ end
56
+
57
+ def generate_html(arguments, command:, default_name:, generator:, warning:)
58
+ options = {}
59
+ parser = OptionParser.new do |option|
60
+ option.banner = "Usage: rubylens #{command} [TARGET] [--output FILE] [--lockfile FILE]"
61
+ option.on("-o", "--output FILE", "Output HTML (default: TARGET/#{default_name})") do |value|
62
+ options[:output] = value
63
+ end
64
+ option.on("--lockfile FILE", "Gemfile.lock used to select exact dependency versions") do |value|
65
+ options[:lockfile] = value
66
+ end
67
+ end
68
+ parser.parse!(arguments)
69
+ target = arguments.shift || Dir.pwd
70
+ raise OptionParser::InvalidArgument, "unexpected argument: #{arguments.first}" unless arguments.empty?
71
+
72
+ result = generator.call(path: target, output: options[:output], lockfile: options[:lockfile])
73
+ print_result(result)
74
+ @stderr.puts warning
75
+ 0
76
+ end
77
+
78
+ def print_result(result)
79
+ @stdout.puts JSON.generate(
80
+ output: result.output_path,
81
+ counts: result.counts,
82
+ warnings: result.warnings,
83
+ )
84
+ end
85
+
86
+ def print_version
87
+ @stdout.puts RubyLens::VERSION
88
+ 0
89
+ end
90
+
91
+ def print_help(status)
92
+ stream = status.zero? ? @stdout : @stderr
93
+ stream.puts <<~HELP
94
+ Usage: rubylens COMMAND
95
+
96
+ Commands:
97
+ report [TARGET] Generate a private, interactive stellar report
98
+ showcase [TARGET] Generate an autonomous, shareable stellar showcase
99
+ version Print the RubyLens version
100
+ HELP
101
+ status
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLens
4
+ class Error < StandardError; end
5
+ class ExtractionError < Error; end
6
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLens
4
+ Result = Data.define(:output_path, :counts, :warnings)
5
+
6
+ class GenerationPipeline
7
+ def initialize(manifest_builder: Index::Manifest, adapter: Index::RubydexAdapter.new, model_builder: ArtModelBuilder.new)
8
+ @manifest_builder = manifest_builder
9
+ @adapter = adapter
10
+ @model_builder = model_builder
11
+ end
12
+
13
+ def call(root:, lockfile: nil)
14
+ manifest = @manifest_builder.build(root: root, lockfile: lockfile)
15
+ snapshot = @adapter.index(manifest)
16
+ model = @model_builder.build(snapshot)
17
+ warning_counts = snapshot.fetch("warning_counts")
18
+ warnings = manifest.warnings.dup
19
+ warnings << "Rubydex reported #{warning_counts.fetch("index")} indexing error(s)." if warning_counts.fetch("index").positive?
20
+ warnings << "Rubydex reported #{warning_counts.fetch("integrity")} integrity issue(s)." if warning_counts.fetch("integrity").positive?
21
+ [model, warnings.freeze]
22
+ end
23
+ end
24
+
25
+ class Generator
26
+ DEFAULT_REPORT_NAME = "rubylens-report.html"
27
+
28
+ def initialize(
29
+ manifest_builder: Index::Manifest,
30
+ adapter: Index::RubydexAdapter.new,
31
+ model_builder: ArtModelBuilder.new,
32
+ report_writer: ReportWriter.new,
33
+ pipeline: nil
34
+ )
35
+ @pipeline = pipeline || GenerationPipeline.new(manifest_builder:, adapter:, model_builder:)
36
+ @report_writer = report_writer
37
+ end
38
+
39
+ def call(path: Dir.pwd, output: nil, lockfile: nil)
40
+ root = File.realpath(path)
41
+ if output.nil?
42
+ output = File.join(root, DEFAULT_REPORT_NAME)
43
+ if File.exist?(output) && !@report_writer.rubylens_report?(output)
44
+ raise Error, "default report path already exists and is not a RubyLens report"
45
+ end
46
+ GitRepository.new(root).exclude_local(output)
47
+ end
48
+ model, warnings = @pipeline.call(root:, lockfile:)
49
+ output_path = @report_writer.write(model, output: output)
50
+
51
+ RubyLens::Result.new(output_path: output_path, counts: model.fetch("totals").freeze, warnings:)
52
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP => error
53
+ raise Error, error.message
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+ require "fileutils"
6
+
7
+ module RubyLens
8
+ class GitRepository
9
+ INDEXABLE_EXTENSIONS = %w[.rb .rake .rbs .ru].freeze
10
+
11
+ attr_reader :target_root, :git_root
12
+
13
+ def initialize(target_root)
14
+ @target_root = Pathname(target_root).expand_path.realpath
15
+ raise ExtractionError, "target is not a directory" unless @target_root.directory?
16
+
17
+ top, status = capture("rev-parse", "--show-toplevel")
18
+ raise ExtractionError, "target must be inside a Git repository" unless status.success?
19
+
20
+ @git_root = Pathname(top.strip).realpath
21
+ unless Paths.inside?(@target_root, @git_root)
22
+ raise ExtractionError, "target is outside its reported Git repository"
23
+ end
24
+ end
25
+
26
+ def metadata
27
+ head, head_status = capture("rev-parse", "--verify", "HEAD")
28
+ branch, branch_status = capture("symbolic-ref", "--quiet", "--short", "HEAD")
29
+ status_output, status_status = capture("status", "--porcelain=v1", "--untracked-files=normal")
30
+ raise ExtractionError, "failed to read Git status" unless status_status.success?
31
+
32
+ {
33
+ "head" => head_status.success? ? head.strip : nil,
34
+ "branch" => branch_status.success? ? branch.strip : nil,
35
+ "dirty" => !status_output.empty?,
36
+ }
37
+ end
38
+
39
+ def selected_files
40
+ output, status = capture("ls-files", "-z", "--cached", "--others", "--exclude-standard")
41
+ raise ExtractionError, "failed to enumerate tracked and unignored files" unless status.success?
42
+
43
+ output.split("\0").filter_map do |relative_to_git|
44
+ next unless INDEXABLE_EXTENSIONS.include?(File.extname(relative_to_git))
45
+
46
+ absolute = @git_root.join(relative_to_git).cleanpath
47
+ next unless Paths.inside?(absolute, @target_root)
48
+ next unless absolute.file?
49
+ resolved = absolute.realpath
50
+ next unless Paths.inside?(resolved, @target_root.realpath)
51
+
52
+ resolved.to_s
53
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
54
+ nil
55
+ end.sort
56
+ end
57
+
58
+ def exclude_local(path, description: "report")
59
+ path = Pathname(path).expand_path
60
+ path = path.dirname.realpath.join(path.basename)
61
+ raise ExtractionError, "local exclude path is outside the Git repository" unless Paths.inside?(path, @git_root)
62
+
63
+ exclude_output, status = capture("rev-parse", "--git-path", "info/exclude")
64
+ raise ExtractionError, "failed to locate Git's local exclude file" unless status.success?
65
+
66
+ exclude_path = Pathname(exclude_output.strip)
67
+ exclude_path = @git_root.join(exclude_path) unless exclude_path.absolute?
68
+ FileUtils.mkdir_p(exclude_path.dirname)
69
+ relative = path.relative_path_from(@git_root).to_s
70
+ _tracked_output, tracked_status = capture("ls-files", "--error-unmatch", "--", relative)
71
+ raise ExtractionError, "default #{description} path is already tracked by Git" if tracked_status.success?
72
+
73
+ directory = File.dirname(relative)
74
+ basename = File.basename(relative)
75
+ escaped_report = escape_ignore_path(relative)
76
+ escaped_temporary = ".#{escape_ignore_path(basename)}.*.tmp"
77
+ escaped_temporary = "#{escape_ignore_path(directory)}/#{escaped_temporary}" unless directory == "."
78
+ entries = ["/#{escaped_report}", "/#{escaped_temporary}"]
79
+ File.open(exclude_path, File::RDWR | File::CREAT, 0o600) do |file|
80
+ file.flock(File::LOCK_EX)
81
+ file.rewind
82
+ contents = file.read
83
+ existing = contents.each_line.map(&:chomp)
84
+ missing = entries - existing
85
+ return false if missing.empty?
86
+
87
+ file.seek(0, IO::SEEK_END)
88
+ file.write("\n") if !contents.empty? && !contents.end_with?("\n")
89
+ file.write("#{missing.join("\n")}\n")
90
+ end
91
+ true
92
+ end
93
+
94
+ private
95
+
96
+ def escape_ignore_path(path)
97
+ path.gsub(/([\\*?\[\]])/, '\\\\\1')
98
+ end
99
+
100
+ def capture(*arguments)
101
+ directory = @git_root || @target_root
102
+ stdout, _stderr, status = Open3.capture3("git", "-C", directory.to_s, *arguments)
103
+ [stdout, status]
104
+ end
105
+ end
106
+ end