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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +67 -0
- data/assets/runtime/report.js +1409 -0
- data/assets/shells/report.html +34 -0
- data/assets/shells/showcase.html +26 -0
- data/assets/styles/report.css +85 -0
- data/assets/styles/showcase.css +15 -0
- data/exe/rubylens +6 -0
- data/lib/rubylens/art_model_builder.rb +94 -0
- data/lib/rubylens/cli.rb +104 -0
- data/lib/rubylens/errors.rb +6 -0
- data/lib/rubylens/generator.rb +56 -0
- data/lib/rubylens/git_repository.rb +106 -0
- data/lib/rubylens/index/manifest.rb +231 -0
- data/lib/rubylens/index/rubydex_adapter.rb +322 -0
- data/lib/rubylens/model/dependency_aggregation.rb +105 -0
- data/lib/rubylens/paths.rb +13 -0
- data/lib/rubylens/report_asset_assembler.rb +38 -0
- data/lib/rubylens/report_writer.rb +61 -0
- data/lib/rubylens/showcase_generator.rb +41 -0
- data/lib/rubylens/showcase_model.rb +41 -0
- data/lib/rubylens/showcase_writer.rb +27 -0
- data/lib/rubylens/version.rb +5 -0
- data/lib/rubylens.rb +33 -0
- metadata +99 -0
|
@@ -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,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
|
data/lib/rubylens/cli.rb
ADDED
|
@@ -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,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
|