rubylens 0.1.0 → 0.2.0

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.
@@ -22,20 +22,20 @@
22
22
  </header>
23
23
  <aside class="panel" id="panel" aria-label="Explore this codebase">
24
24
  <div class="panel-header">
25
- <div><h2>Explore this codebase</h2><p>Show a system, focus its stars, or fly to a Ruby code highlight.</p></div>
26
- <div class="panel-header-actions"><button type="button" id="panel-toggle" aria-expanded="true" aria-controls="panel-body">Hide</button></div>
25
+ <div class="panel-title"><p class="panel-kicker">Stellar navigator</p><h2>Explorer</h2></div>
26
+ <div class="panel-header-actions"><button type="button" id="panel-toggle" aria-label="Collapse Explorer" aria-expanded="true" aria-controls="panel-body">Collapse</button></div>
27
27
  </div>
28
28
  <div id="panel-body">
29
29
  <div class="explorer-search" id="explorer-search-region" role="search">
30
- <label for="explorer-search">Search classes, modules, and gems</label>
31
- <input type="search" id="explorer-search" placeholder="Class, module, or gem name" autocomplete="off" spellcheck="false" aria-controls="search-results" aria-describedby="search-status">
32
- <p class="search-status" id="search-status" role="status" aria-live="polite">Search Core, Tests, and Gems.</p>
30
+ <label for="explorer-search">Find a star or system</label>
31
+ <input type="search" id="explorer-search" placeholder="Class, module, or gem" autocomplete="off" spellcheck="false" aria-controls="search-results" aria-describedby="search-status">
32
+ <p class="search-status" id="search-status" role="status" aria-live="polite"></p>
33
33
  <div class="search-results" id="search-results" role="region" aria-label="Search results" hidden></div>
34
34
  </div>
35
35
  <div id="controls"></div>
36
- <div class="foot">Double-click a dependency system or gem cloud, press Enter or F on its selected marker, or tap that marker again to expand it. Escape exits spatial focus; Reset restores the default view. 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>
36
+ <div class="foot">Keep private — includes codebase names and structure, but not source or file paths.</div></div>
37
37
  </aside>
38
- <div class="toolbar"><button type="button" id="motion" aria-label="Pause drift" aria-keyshortcuts="Space" aria-pressed="false" title="Pause drift (Space)">Pause drift</button><button type="button" id="reset-view" aria-label="Reset to default view" aria-keyshortcuts="0" title="Reset view (0)">Reset</button><button type="button" id="pan-mode" aria-label="Pan mode" aria-keyshortcuts="P" aria-pressed="false" title="Pan mode (P)">Pan</button><button type="button" id="zoom-out" aria-label="Zoom out" aria-keyshortcuts="-" title="Zoom out (−)">−</button><output class="zoom-level" id="zoom-level" aria-label="Zoom level">200%</output><button type="button" id="zoom-in" aria-label="Zoom in" aria-keyshortcuts="+" title="Zoom in (+)">+</button><button type="button" id="help-open" aria-label="Keyboard shortcuts" aria-keyshortcuts="?" aria-haspopup="dialog" title="Keyboard shortcuts (?)">?</button></div>
38
+ <div class="toolbar"><button type="button" id="motion" aria-label="Pause drift" aria-keyshortcuts="Space" aria-pressed="false" title="Pause drift (Space)">Pause drift</button><button type="button" id="reset-view" aria-label="Reset to default view" aria-keyshortcuts="0" title="Reset view (0)">Reset</button><button type="button" id="pan-mode" aria-label="Pan mode" aria-keyshortcuts="P" aria-pressed="false" title="Pan mode (P)">Pan</button><button type="button" id="zoom-out" aria-label="Zoom out" aria-keyshortcuts="-" title="Zoom out (−)">−</button><output class="zoom-level" id="zoom-level" aria-label="Zoom level">200%</output><button type="button" id="zoom-in" aria-label="Zoom in" aria-keyshortcuts="+" title="Zoom in (+)">+</button><button type="button" id="ui-toggle" aria-label="Hide interface" aria-keyshortcuts="H" aria-pressed="false" title="Hide interface (H)">Hide UI</button><button type="button" id="help-open" aria-label="Keyboard shortcuts" aria-keyshortcuts="?" aria-haspopup="dialog" title="Keyboard shortcuts (?)">?</button></div>
39
39
  <div class="hint">Drag to orbit · scroll to zoom · press ? for all shortcuts</div>
40
40
  <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>
41
41
  <div class="help-overlay" id="shortcuts-help" role="dialog" aria-modal="true" aria-label="Shortcuts and controls" hidden>
@@ -49,6 +49,7 @@
49
49
  <div><dt>Shift-drag or right-drag</dt><dd>Move the view</dd></div>
50
50
  <div><dt>Scroll or pinch</dt><dd>Zoom at the cursor</dd></div>
51
51
  <div><dt>Click a star</dt><dd>Select and fly to it</dd></div>
52
+ <div><dt>Tap while UI is hidden</dt><dd>Show the interface</dd></div>
52
53
  <div><dt>Double-click a gem cloud</dt><dd>Expand its stars</dd></div>
53
54
  <div><dt>Double-click empty space</dt><dd>Zoom in · Shift zooms out</dd></div>
54
55
  </dl>
@@ -63,6 +64,7 @@
63
64
  <div><dt>Space</dt><dd>Pause or resume drift</dd></div>
64
65
  <div><dt>Enter / F</dt><dd>Expand the selected gem cloud</dd></div>
65
66
  <div><dt>/</dt><dd>Search</dd></div>
67
+ <div><dt>H</dt><dd>Hide or show the interface</dd></div>
66
68
  <div><dt>Esc</dt><dd>Exit focus and fly back</dd></div>
67
69
  <div><dt>?</dt><dd>Show or hide this list</dd></div>
68
70
  </dl>
@@ -13,6 +13,7 @@
13
13
  <body data-rubylens-mode="showcase">
14
14
  <main class="showcase-stage" id="showcase-stage">
15
15
  <canvas id="cosmos" role="img" aria-label="Stellar artwork of a Ruby codebase."></canvas>
16
+ <canvas id="travel-cosmos" aria-hidden="true"></canvas>
16
17
  <header class="masthead">
17
18
  <p class="eyebrow">RubyLens · codebase galaxy</p>
18
19
  <h1>Ruby project</h1>
@@ -8,6 +8,12 @@
8
8
  canvas.is-star:not(.is-pan):not(.is-dragging-pan):not(:active) { cursor: pointer; }
9
9
  canvas.is-pan:active, canvas.is-dragging-pan:active { cursor: grabbing; }
10
10
  canvas:focus-visible { outline: 2px solid rgba(216, 200, 255, .85); outline-offset: -4px; }
11
+ body.is-ui-hidden .masthead,
12
+ body.is-ui-hidden .panel,
13
+ body.is-ui-hidden .toolbar,
14
+ body.is-ui-hidden .hint,
15
+ body.is-ui-hidden .tooltip,
16
+ body.is-ui-hidden .help-overlay { display: none; }
11
17
  .masthead { position: fixed; top: 28px; left: 32px; z-index: 2; pointer-events: none; max-width: 760px; }
12
18
  .eyebrow { margin: 0 0 10px; color: #b6a9c9; font-size: 11px; font-weight: 750; letter-spacing: .18em; text-transform: uppercase; }
13
19
  h1 { margin: 0; font: 500 clamp(28px, 4vw, 54px)/.95 ui-serif, Georgia, serif; letter-spacing: -.045em; }
@@ -25,14 +31,15 @@
25
31
  .warning-row { display: grid; gap: 2px; }
26
32
  .warning-row strong { color: #f1e9dc; font-size: 11px; overflow-wrap: anywhere; }
27
33
  .warning-row span, .warning-note { margin: 0; color: #958875; font-size: 9px; line-height: 1.45; }
28
- .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); }
34
+ .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, .86); box-shadow: 0 24px 80px rgba(0,0,0,.42); backdrop-filter: blur(18px); }
29
35
  .panel.is-collapsed { width: 190px; overflow: hidden; }
30
- .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); }
36
+ .panel-header { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 18px 15px; border-bottom: 1px solid rgba(255,255,255,.08); background: rgba(9, 9, 18, .94); }
31
37
  .panel.is-collapsed .panel-header { align-items: center; padding-block: 13px; border-bottom: 0; }
32
- .panel.is-collapsed .panel-header p { display: none; }
38
+ .panel.is-collapsed .panel-kicker { display: none; }
39
+ .panel-title { display: grid; gap: 3px; min-width: 0; }
40
+ .panel-kicker { margin: 0; color: #a99db8; font-size: 9px; font-weight: 750; letter-spacing: .14em; line-height: 1.2; text-transform: uppercase; }
33
41
  .panel-header-actions { display: flex; flex: none; gap: 6px; }
34
- .panel h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
35
- .panel-header p { margin: 4px 0 0; color: #8d8798; font-size: 11px; line-height: 1.4; }
42
+ .panel h2 { margin: 0; color: #f3eef8; font-size: 15px; letter-spacing: -.02em; }
36
43
  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; }
37
44
  button:hover { background: rgba(255,255,255,.1); }
38
45
  button:disabled { opacity: .45; cursor: not-allowed; }
@@ -42,40 +49,59 @@
42
49
  summary::-webkit-details-marker { display: none; }
43
50
  summary::after { content: "⌄"; margin-left: auto; color: #777080; font-size: 12px; transition: transform 160ms cubic-bezier(.16, 1, .3, 1); }
44
51
  details[open] summary::after { transform: rotate(180deg); }
45
- .swatch { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 14px currentColor; }
46
- .swatch.core { color: #f15f87; background: currentColor; }
47
- .swatch.tests { color: #69d7ff; background: currentColor; }
48
- .swatch.dependencies { color: #ffc86a; background: currentColor; }
49
- .section-heading { display: grid; gap: 2px; }
52
+ .explorer-section { --accent: 216, 200, 255; }
53
+ .explorer-section.core { --accent: 232, 126, 158; }
54
+ .explorer-section.tests { --accent: 110, 192, 222; }
55
+ .explorer-section.dependencies { --accent: 218, 166, 87; }
56
+ .explorer-section > summary { gap: 10px; min-height: 58px; padding-block: 12px; }
57
+ .explorer-section > summary::before { content: ""; align-self: stretch; flex: 0 0 1px; width: 1px; border-radius: 1px; background: rgba(var(--accent), .48); transition: background-color 180ms cubic-bezier(.16, 1, .3, 1); }
58
+ .explorer-section[open] > summary { background: linear-gradient(90deg, rgba(var(--accent), .075), transparent 72%); }
59
+ .explorer-section[open] > summary::before { background: rgba(var(--accent), .92); }
60
+ .explorer-section > summary::after { margin-left: 0; }
61
+ .section-heading { display: grid; flex: 1 1 auto; gap: 3px; min-width: 0; }
50
62
  .section-heading strong { color: #eee9f4; font-size: 12px; }
51
- .section-heading small { color: #837c8d; font-size: 10px; font-weight: 500; }
63
+ .section-heading small { overflow: hidden; color: #958e9f; font-size: 9px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
64
+ .section-state { flex: none; color: #9992a2; font-size: 8px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
65
+ .section-state .focused, .section-state .hidden { display: none; }
66
+ .explorer-section:has(.section-actions button[aria-pressed="true"]) .section-state .visible { display: none; }
67
+ .explorer-section:has(.section-actions button[aria-pressed="true"]) .section-state .focused { display: inline; color: rgba(var(--accent), .95); }
68
+ .explorer-section:has(.visibility input:not(:checked)) .section-state .visible,
69
+ .explorer-section:has(.visibility input:not(:checked)) .section-state .focused { display: none; }
70
+ .explorer-section:has(.visibility input:not(:checked)) .section-state .hidden { display: inline; }
52
71
  .section-note { margin: -4px 0 13px; color: #756e7e; font-size: 9px; line-height: 1.4; }
53
72
  .section-body { padding: 0 18px 16px; }
54
- .section-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
73
+ .section-actions { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
55
74
  .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); }
56
75
  .ruby-breakdown div { min-width: 0; }
57
76
  .ruby-breakdown dt, .ruby-breakdown dd { margin: 0; }
58
77
  .ruby-breakdown dt { color: #837c8d; font-size: 9px; }
59
78
  .ruby-breakdown dd { margin-top: 2px; color: #f3edf9; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
60
- .section-actions button[aria-pressed="true"] { border-color: rgba(216,200,255,.36); background: rgba(216,200,255,.14); color: #fff; }
61
- .visibility { display: inline-flex; align-items: center; gap: 7px; margin-right: auto; color: #b8b1c0; font-size: 11px; cursor: pointer; }
62
- .visibility input { width: 15px; height: 15px; margin: 0; accent-color: #d8c8ff; }
63
- .fact-label { margin: 0 0 7px; color: #7f788b; font-size: 10px; }
64
- .facts { display: grid; gap: 5px; }
65
- .fact { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; width: 100%; padding: 9px 10px; text-align: left; }
66
- .fact:hover { border-color: rgba(255,255,255,.2); }
79
+ .section-actions button[aria-pressed="true"] { border-color: rgba(var(--accent), .4); background: rgba(var(--accent), .13); color: #fff; }
80
+ .visibility { position: relative; display: inline-flex; align-items: center; gap: 8px; margin-right: auto; color: #b8b1c0; font-size: 11px; cursor: pointer; }
81
+ .visibility input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
82
+ .visibility-track { position: relative; width: 25px; height: 14px; border: 1px solid rgba(255,255,255,.2); border-radius: 999px; background: rgba(255,255,255,.07); transition: border-color 160ms ease, background-color 160ms ease; }
83
+ .visibility-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 8px; height: 8px; border-radius: 50%; background: #8d8796; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background-color 160ms ease; }
84
+ .visibility input:checked + .visibility-track { border-color: rgba(var(--accent), .45); background: rgba(var(--accent), .14); }
85
+ .visibility input:checked + .visibility-track::after { background: rgb(var(--accent)); transform: translateX(11px); }
86
+ .visibility input:focus-visible + .visibility-track { outline: 2px solid rgba(216, 200, 255, .9); outline-offset: 2px; }
87
+ .fact-label { margin: 0 0 5px; color: #8d8697; font-size: 9px; font-weight: 650; }
88
+ .facts { border-block: 1px solid rgba(255,255,255,.075); }
89
+ .fact { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; width: 100%; border: 0; border-radius: 0; padding: 9px 7px; background: transparent; text-align: left; transition: background-color 160ms ease; }
90
+ .fact + .fact { border-top: 1px solid rgba(255,255,255,.065); }
91
+ .fact:hover { background: rgba(var(--accent), .065); }
67
92
  .fact-title { display: flex; align-items: center; gap: 6px; min-width: 0; color: #a9a2b1; font-size: 10px; }
68
93
  .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; }
69
94
  .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; }
70
95
  .fact-name { overflow: hidden; color: #ece6f2; font-size: 11px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
71
- .fact[aria-pressed="true"] { border-color: rgba(255,255,255,.28); background: rgba(255,255,255,.12); }
72
- .explorer-search { display: grid; gap: 7px; padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,.08); }
96
+ .fact[aria-pressed="true"] { background: rgba(var(--accent), .13); }
97
+ .explorer-search { display: grid; gap: 7px; padding: 14px 18px 15px; border-bottom: 1px solid rgba(255,255,255,.08); }
73
98
  .explorer-search[hidden] { display: none; }
74
99
  .explorer-search label { color: #b8b1c0; font-size: 10px; font-weight: 700; }
75
100
  .explorer-search input { width: 100%; min-width: 0; border: 1px solid rgba(255,255,255,.13); border-radius: 9px; padding: 9px 10px; background: rgba(255,255,255,.055); color: #f4eff8; font: inherit; font-size: 12px; }
76
101
  .explorer-search input::placeholder { color: #8f8998; opacity: 1; }
77
102
  .explorer-search input:focus-visible { outline: 2px solid rgba(216, 200, 255, .9); outline-offset: 2px; }
78
- .search-status { min-height: 14px; margin: 0; color: #817a89; font-size: 9px; line-height: 1.45; }
103
+ .search-status { margin: 0; color: #9a93a3; font-size: 9px; line-height: 1.45; }
104
+ .search-status:empty { display: none; }
79
105
  .search-results { display: grid; gap: 5px; }
80
106
  .search-results[hidden] { display: none; }
81
107
  .search-result { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; width: 100%; padding: 9px 10px; text-align: left; }
@@ -119,9 +145,9 @@
119
145
  .warning-details { max-height: clamp(48px, calc(54vh - 220px), 240px); }
120
146
  .panel { top: auto; bottom: 14px; right: 14px; left: 14px; width: auto; max-height: 46vh; }
121
147
  .panel.is-collapsed { right: 14px; left: auto; width: 190px; }
122
- .toolbar { right: 14px; left: 14px; bottom: 88px; gap: 5px; }
123
- .toolbar button { min-height: 40px; padding-inline: 8px; }
148
+ .toolbar { right: 14px; left: 14px; bottom: 88px; flex-wrap: wrap; gap: 4px; }
149
+ .toolbar button { min-height: 40px; padding-inline: 6px; white-space: nowrap; }
124
150
  .panel:not(.is-collapsed) ~ .toolbar { bottom: calc(46vh + 28px); }
125
151
  .hint { display: none; }
126
152
  }
127
- @media (prefers-reduced-motion: reduce) { .panel, summary::after { transition: none; } }
153
+ @media (prefers-reduced-motion: reduce) { summary::after { transition: none; } }
@@ -39,6 +39,9 @@
39
39
  @media (prefers-reduced-motion: reduce) {
40
40
  .cinema-annotation, .cinema-annotation::before, .cinema-annotation::after, .cinema-annotation-copy { transition: none; }
41
41
  }
42
+ /* Clip export drives annotation opacity per frame from a synthetic clock;
43
+ wall-clock CSS fades would make captured frames nondeterministic. */
44
+ html[data-rubylens-clip] .cinema-annotation, html[data-rubylens-clip] .cinema-annotation::before, html[data-rubylens-clip] .cinema-annotation::after, html[data-rubylens-clip] .cinema-annotation-copy { transition: none; }
42
45
  @media (max-width: 600px) {
43
46
  .galaxy-summary { font-size: 24px; }
44
47
  .showcase-status { max-width: 720px; font-size: 24px; }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "dependency_warning"
4
+ require_relative "errors"
4
5
  require_relative "morphology_classifier"
5
6
 
6
7
  module RubyLens
@@ -15,6 +16,9 @@ module RubyLens
15
16
  random = Random.new(@seed)
16
17
  morphology = MorphologyClassifier.new(snapshot).call
17
18
  namespace_order = (0...snapshot.fetch("namespaces").length).to_a.shuffle(random: random)
19
+ namespace_index = Array.new(namespace_order.length)
20
+ namespace_order.each_with_index { |old_index, new_index| namespace_index[old_index] = new_index }
21
+ constant_reference_rows = valid_constant_reference_rows(snapshot)
18
22
  namespaces = namespace_order.map do |index|
19
23
  row = snapshot.fetch("namespaces").fetch(index)
20
24
  [random.rand(0..0xffff_ffff), *row]
@@ -26,7 +30,6 @@ module RubyLens
26
30
  package_morphologies = []
27
31
  packages = package_order.map do |old_index|
28
32
  package = snapshot.fetch("packages").fetch(old_index)
29
- declaration_count = package.fetch("declaration_count")
30
33
  package_seed = random.rand(0..0xffff_ffff)
31
34
  package_morphology = MorphologyClassifier.new(package:, phase_seed: package_seed).call
32
35
  package_morphologies << [package_morphology.fetch("family"), *package_morphology.fetch("knobs")]
@@ -34,20 +37,37 @@ module RubyLens
34
37
  package_seed,
35
38
  package.fetch("role"),
36
39
  package.fetch("location"),
37
- declaration_count,
40
+ package.fetch("declarations").length,
38
41
  *package.fetch("ruby_counts"),
39
42
  dependency_system_index.fetch(old_index, -1),
40
43
  ]
41
44
  end
42
45
  package_names = package_order.map { |old_index| snapshot.fetch("packages").fetch(old_index).fetch("name") }
43
- indexed_dependency_count = snapshot.fetch("packages").sum do |package|
44
- package.fetch("declaration_count")
46
+ dependency_offsets = []
47
+ dependency_count = 0
48
+ snapshot.fetch("packages").each do |package|
49
+ dependency_offsets << dependency_count
50
+ dependency_count += package.fetch("declarations").length
51
+ end
52
+ dependency_target_rows = {}.compare_by_identity
53
+ constant_reference_rows.each do |_referring_index, referenced_index|
54
+ dependency_ordinal = referenced_index - namespace_order.length
55
+ next if dependency_ordinal.negative? || dependency_ordinal >= dependency_count
56
+
57
+ next_package_index = dependency_offsets.bsearch_index { |offset| offset > dependency_ordinal }
58
+ old_package_index = (next_package_index || dependency_offsets.length) - 1
59
+ package = snapshot.fetch("packages").fetch(old_package_index)
60
+ local_index = dependency_ordinal - dependency_offsets.fetch(old_package_index)
61
+ dependency_target_rows[package.fetch("declarations").fetch(local_index)] = dependency_ordinal
45
62
  end
46
63
  dependencies = []
64
+ dependency_index = {}
47
65
  package_order.each do |old_index|
48
66
  package = snapshot.fetch("packages").fetch(old_index)
49
67
  declarations = package.fetch("declarations").sort.shuffle(random: random)
50
68
  declarations.each do |declaration|
69
+ original_ordinal = dependency_target_rows[declaration]
70
+ dependency_index[original_ordinal] = dependencies.length if original_ordinal
51
71
  dependencies << [
52
72
  random.rand(0..0xffff_ffff),
53
73
  package_index.fetch(old_index),
@@ -55,21 +75,25 @@ module RubyLens
55
75
  ]
56
76
  end
57
77
  end
78
+ constant_reference_links = build_constant_reference_links(
79
+ constant_reference_rows,
80
+ namespace_index,
81
+ dependency_index,
82
+ )
58
83
  {
59
- "schema" => "rubylens.art.v10",
84
+ "schema" => "rubylens.art.v13",
60
85
  "projectName" => snapshot.fetch("project_name"),
61
- "morphology" => morphology,
86
+ "morphology" => [morphology.fetch("family"), *morphology.fetch("knobs")],
62
87
  "totals" => {
63
88
  "namespaces" => namespaces.length,
64
89
  "packages" => packages.length,
65
- "dependencyStars" => indexed_dependency_count,
66
- "renderedDependencyStars" => dependencies.length,
90
+ "dependencyStars" => dependencies.length,
67
91
  },
68
92
  "domains" => signal_domains(namespaces, snapshot.fetch("dependency_signal_maxima")),
69
- "componentCounts" => snapshot.fetch("components"),
70
93
  "categoryStats" => snapshot.fetch("category_stats"),
71
94
  "namespaceNames" => namespace_names,
72
95
  "namespaces" => namespaces,
96
+ "constantReferenceLinks" => constant_reference_links,
73
97
  "packageNames" => package_names,
74
98
  "packages" => packages,
75
99
  "packageMorphologies" => package_morphologies,
@@ -89,6 +113,34 @@ module RubyLens
89
113
 
90
114
  private
91
115
 
116
+ def valid_constant_reference_rows(snapshot)
117
+ rows = snapshot.fetch("constant_reference_links", [])
118
+ return [] unless rows.is_a?(Array)
119
+
120
+ rows.select do |row|
121
+ row.is_a?(Array) && row.length == 2 && row.all?(Integer) && row.none?(&:negative?)
122
+ end
123
+ end
124
+
125
+ def build_constant_reference_links(rows, namespace_index, dependency_index)
126
+ snapshot_namespace_count = namespace_index.length
127
+ candidates = rows.filter_map do |row|
128
+ referring_index, referenced_index = row
129
+ remapped_referring_index = namespace_index[referring_index]
130
+ remapped_referenced_index = if referenced_index < snapshot_namespace_count
131
+ namespace_index[referenced_index]
132
+ else
133
+ dependency = dependency_index[referenced_index - snapshot_namespace_count]
134
+ snapshot_namespace_count + dependency if dependency
135
+ end
136
+ next if remapped_referring_index.nil? || remapped_referenced_index.nil?
137
+
138
+ [remapped_referring_index, remapped_referenced_index]
139
+ end
140
+
141
+ candidates.shuffle(random: Random.new(@seed ^ 0xC057_A17E))
142
+ end
143
+
92
144
  def build_dependency_systems(snapshot, package_index)
93
145
  system_random = Random.new(@seed ^ 0xD3E5_157E)
94
146
  package_system_index = {}
@@ -105,7 +157,7 @@ module RubyLens
105
157
  end
106
158
 
107
159
  def signal_domains(namespaces, dependency_maxima)
108
- namespace_columns = [4, 5, 6, 7, 8, 9]
160
+ namespace_columns = [3, 4, 5, 6, 7, 8]
109
161
  namespace_domains = namespace_columns.map { |column| maximum(namespaces, column) }
110
162
  SIGNAL_FIELDS.each_with_index.to_h do |field, index|
111
163
  [field, [namespace_domains[index], dependency_maxima[index]].max]
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLens
4
+ # Recognizes RubyLens's own artifacts by scanning the head of a file for a
5
+ # format marker, so default output paths are only ever replaced in kind.
6
+ module ArtifactMarker
7
+ HEAD_BYTES = 2048
8
+
9
+ module_function
10
+
11
+ def present?(path, marker, head_bytes: HEAD_BYTES)
12
+ File.file?(path) && File.open(path, "rb") { |file| file.read(head_bytes).to_s.include?(marker) }
13
+ rescue Errno::ENOENT, Errno::EACCES
14
+ false
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module RubyLens
7
+ # Owner-only atomic replacement shared by every writer: outputs are staged in
8
+ # a 0600 temporary alongside the target and renamed into place, so a partial
9
+ # write never becomes visible and permissions never loosen mid-write.
10
+ module AtomicOutput
11
+ module_function
12
+
13
+ def replace(output)
14
+ output = File.expand_path(output)
15
+ FileUtils.mkdir_p(File.dirname(output), mode: 0o700)
16
+ temporary = File.join(File.dirname(output), ".#{File.basename(output)}.#{SecureRandom.hex(6)}.tmp")
17
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600).close
18
+ yield temporary
19
+ File.chmod(0o600, temporary)
20
+ File.rename(temporary, output)
21
+ File.chmod(0o600, output)
22
+ output
23
+ ensure
24
+ FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
25
+ end
26
+ end
27
+ end
data/lib/rubylens/cli.rb CHANGED
@@ -6,6 +6,35 @@ require_relative "../rubylens"
6
6
 
7
7
  module RubyLens
8
8
  class CLI
9
+ # One entry per generating command: everything that differs between
10
+ # report, showcase, and clip lives here, not in per-command methods.
11
+ COMMANDS = {
12
+ "report" => {
13
+ default_name: Generator::DEFAULT_REPORT_NAME,
14
+ generator: :generate_report,
15
+ warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
16
+ details_option: false,
17
+ output_kind: "HTML",
18
+ report_progress: false,
19
+ },
20
+ "clip" => {
21
+ default_name: ClipGenerator::DEFAULT_CLIP_NAME,
22
+ generator: :generate_clip,
23
+ warning: "RubyLens clips show the same picture as showcases: the project name plus the galaxy's shape and scale; --details adds aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
24
+ details_option: true,
25
+ output_kind: "MP4",
26
+ report_progress: true,
27
+ },
28
+ "showcase" => {
29
+ default_name: ShowcaseGenerator::DEFAULT_SHOWCASE_NAME,
30
+ generator: :generate_showcase,
31
+ warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
32
+ details_option: true,
33
+ output_kind: "HTML",
34
+ report_progress: false,
35
+ },
36
+ }.freeze
37
+
9
38
  def initialize(arguments)
10
39
  @arguments = arguments.dup
11
40
  end
@@ -15,8 +44,7 @@ module RubyLens
15
44
  command = arguments.shift
16
45
  return print_version if %w[-v --version version].include?(command)
17
46
  return print_help(0) if command.nil? || %w[-h --help help].include?(command)
18
- return generate_report(arguments) if command == "report"
19
- return generate_showcase(arguments) if command == "showcase"
47
+ return generate(command, arguments) if COMMANDS.key?(command)
20
48
 
21
49
  $stderr.puts "Unknown command: #{command}"
22
50
  print_help(2)
@@ -27,28 +55,8 @@ module RubyLens
27
55
 
28
56
  private
29
57
 
30
- def generate_report(arguments)
31
- generate_html(
32
- arguments,
33
- command: "report",
34
- default_name: "rubylens-report.html",
35
- generator: RubyLens.method(:generate_report),
36
- warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
37
- )
38
- end
39
-
40
- def generate_showcase(arguments)
41
- generate_html(
42
- arguments,
43
- command: "showcase",
44
- default_name: "rubylens-showcase.html",
45
- generator: RubyLens.method(:generate_showcase),
46
- warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
47
- details_option: true,
48
- )
49
- end
50
-
51
- def generate_html(arguments, command:, default_name:, generator:, warning:, details_option: false)
58
+ def generate(command, arguments)
59
+ specification = COMMANDS.fetch(command)
52
60
  options = {}
53
61
  help = false
54
62
  parser = OptionParser.new do |option|
@@ -56,13 +64,13 @@ module RubyLens
56
64
  option.separator ""
57
65
  option.separator "TARGET defaults to the current working directory."
58
66
  option.separator ""
59
- option.on("-o", "--output FILE", "Output HTML (default: TARGET/#{default_name})") do |value|
67
+ option.on("-o", "--output FILE", "Output #{specification.fetch(:output_kind)} (default: TARGET/#{specification.fetch(:default_name)})") do |value|
60
68
  options[:output] = value
61
69
  end
62
70
  option.on("--lockfile FILE", "Gemfile.lock used to select exact dependency versions") do |value|
63
71
  options[:lockfile] = value
64
72
  end
65
- if details_option
73
+ if specification.fetch(:details_option)
66
74
  option.on("--details", "Include aggregate statistics and cinematic code/dependency labels") do
67
75
  options[:details] = true
68
76
  end
@@ -81,19 +89,27 @@ module RubyLens
81
89
  raise OptionParser::InvalidArgument, "unexpected argument: #{arguments.first}" unless arguments.empty?
82
90
 
83
91
  generator_options = { path: target, output: options[:output], lockfile: options[:lockfile] }
84
- generator_options[:details] = options.fetch(:details, false) if details_option
85
- result = generator.call(**generator_options)
92
+ generator_options[:details] = options.fetch(:details, false) if specification.fetch(:details_option)
93
+ generator_options[:progress] = progress_printer if specification.fetch(:report_progress)
94
+ result = RubyLens.public_send(specification.fetch(:generator), **generator_options)
86
95
  print_result(result)
87
- $stderr.puts warning
96
+ $stderr.puts specification.fetch(:warning)
88
97
  0
89
98
  end
90
99
 
100
+ def progress_printer
101
+ last_step = -1
102
+ lambda do |rendered, total|
103
+ step = rendered * 10 / total
104
+ next unless step > last_step
105
+
106
+ last_step = step
107
+ $stderr.puts "Rendering clip: #{rendered * 100 / total}% (#{rendered}/#{total} frames)"
108
+ end
109
+ end
110
+
91
111
  def print_result(result)
92
- $stdout.puts JSON.generate(
93
- output: result.output_path,
94
- counts: result.counts,
95
- warnings: result.warnings,
96
- )
112
+ $stdout.puts JSON.generate(result.to_payload)
97
113
  end
98
114
 
99
115
  def print_version
@@ -110,10 +126,11 @@ module RubyLens
110
126
 
111
127
  Commands:
112
128
  report [OPTIONS] [TARGET] Generate a private, interactive galaxy report
113
- showcase [OPTIONS] [TARGET] Generate a shareable, cinematic galaxy showcase
129
+ clip [OPTIONS] [TARGET] Generate a shareable galaxy video (MP4, needs Chrome + ffmpeg)
130
+ showcase [OPTIONS] [TARGET] Generate a shareable, cinematic galaxy showcase page
114
131
  version Print the RubyLens version
115
132
 
116
- Run `rubylens report --help` or `rubylens showcase --help` for options.
133
+ Run `rubylens report --help`, `rubylens clip --help`, or `rubylens showcase --help` for options.
117
134
  HELP
118
135
  status
119
136
  end