rubylens 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -39
  3. data/assets/runtime/report.js +1607 -251
  4. data/assets/shells/report.html +9 -7
  5. data/assets/shells/showcase.html +1 -0
  6. data/assets/styles/report.css +51 -25
  7. data/assets/styles/showcase.css +3 -0
  8. data/lib/rubylens/art_model_builder.rb +214 -72
  9. data/lib/rubylens/artifact_marker.rb +17 -0
  10. data/lib/rubylens/atomic_output.rb +27 -0
  11. data/lib/rubylens/cli.rb +53 -36
  12. data/lib/rubylens/clip/chrome_page.rb +183 -0
  13. data/lib/rubylens/clip/deadline_io.rb +28 -0
  14. data/lib/rubylens/clip/renderer.rb +113 -0
  15. data/lib/rubylens/clip/toolchain.rb +82 -0
  16. data/lib/rubylens/clip/web_socket_channel.rb +116 -0
  17. data/lib/rubylens/clip_generator.rb +102 -0
  18. data/lib/rubylens/default_output.rb +24 -0
  19. data/lib/rubylens/generator.rb +9 -10
  20. data/lib/rubylens/git_repository.rb +13 -7
  21. data/lib/rubylens/index/constant_reference_collector.rb +150 -0
  22. data/lib/rubylens/index/declaration_collector.rb +288 -0
  23. data/lib/rubylens/index/git_package_source.rb +202 -0
  24. data/lib/rubylens/index/location_index.rb +103 -0
  25. data/lib/rubylens/index/manifest.rb +62 -157
  26. data/lib/rubylens/index/rspec_extractor.rb +12 -10
  27. data/lib/rubylens/index/rubydex_adapter.rb +111 -301
  28. data/lib/rubylens/index/source_path.rb +12 -11
  29. data/lib/rubylens/model/dependency_aggregation.rb +11 -7
  30. data/lib/rubylens/morphology_classifier.rb +52 -12
  31. data/lib/rubylens/report_writer.rb +5 -20
  32. data/lib/rubylens/showcase_generator.rb +5 -9
  33. data/lib/rubylens/showcase_model.rb +12 -14
  34. data/lib/rubylens/showcase_writer.rb +0 -6
  35. data/lib/rubylens/version.rb +1 -1
  36. data/lib/rubylens.rb +7 -0
  37. metadata +16 -3
@@ -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,94 +1,237 @@
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
7
8
  class ArtModelBuilder
9
+ SCHEMA = "rubylens.art.v13"
8
10
  SIGNAL_FIELDS = %w[ancestorDepth definitionSites reopenings descendants references members].freeze
11
+ # Namespace row columns carrying the six signals, aligned with SIGNAL_FIELDS.
12
+ NAMESPACE_SIGNAL_COLUMNS = (3..8).freeze
9
13
 
14
+ # `order` lists snapshot ordinals in draw order and is the single authority
15
+ # for each shuffle; the snapshot-ordinal-to-draw-position inverse is derived
16
+ # where it is needed rather than stored beside it.
17
+ Namespaces = Data.define(
18
+ :order, #: Array[Integer]
19
+ :rows, #: Array[Array[Integer]]
20
+ :names, #: Array[String]
21
+ :reference_rows, #: Array[[Integer, Integer]]
22
+ ) do
23
+ # Link endpoints past the namespace block address dependency stars, which
24
+ # the snapshot numbers from zero after the namespaces.
25
+ #
26
+ #: () -> Array[Integer]
27
+ def referenced_dependency_ordinals
28
+ reference_rows.map { |_referring_index, referenced_index| referenced_index - order.length }
29
+ end
30
+
31
+ #: () -> Array[Integer]
32
+ def draw_positions
33
+ positions = Array.new(order.length)
34
+ order.each_with_index { |old_index, new_index| positions[old_index] = new_index }
35
+ positions
36
+ end
37
+ end
38
+
39
+ Packages = Data.define(
40
+ :order, #: Array[Integer]
41
+ :rows, #: Array[Array[Integer]]
42
+ :names, #: Array[String]
43
+ :morphologies, #: Array[Array[Integer]]
44
+ :systems, #: Array[Array[Integer]]
45
+ )
46
+
47
+ # Unlike the other two, this index is not an inverse of any stored order:
48
+ # a dependency star's snapshot ordinal is only known for rows travel
49
+ # targets, and is captured as the shuffled rows are emitted.
50
+ Dependencies = Data.define(
51
+ :rows, #: Array[Array[Integer]]
52
+ :index, #: Hash[Integer, Integer]
53
+ )
54
+
55
+ #: (?seed: Integer) -> void
10
56
  def initialize(seed: 0x51A7_E11A)
11
57
  @seed = seed
12
58
  end
13
59
 
60
+ # Draw order is shuffled so neighbouring stars come from unrelated parts of
61
+ # the codebase: adjacent points would otherwise share a package or a name
62
+ # prefix and read as structure the galaxy does not mean to show. Every
63
+ # ordinal the snapshot used is therefore remapped onto the shuffled order.
64
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
14
65
  def build(snapshot)
15
66
  random = Random.new(@seed)
16
- morphology = MorphologyClassifier.new(snapshot).call
17
- namespace_order = (0...snapshot.fetch("namespaces").length).to_a.shuffle(random: random)
18
- namespaces = namespace_order.map do |index|
19
- row = snapshot.fetch("namespaces").fetch(index)
20
- [random.rand(0..0xffff_ffff), *row]
21
- end
22
- namespace_names = namespace_order.map { |index| snapshot.fetch("namespace_names").fetch(index) }
23
- package_order = (0...snapshot.fetch("packages").length).to_a.shuffle(random: random)
24
- package_index = package_order.each_with_index.to_h
25
- dependency_systems, dependency_system_index = build_dependency_systems(snapshot, package_index)
26
- package_morphologies = []
27
- packages = package_order.map do |old_index|
28
- package = snapshot.fetch("packages").fetch(old_index)
29
- declaration_count = package.fetch("declaration_count")
30
- package_seed = random.rand(0..0xffff_ffff)
31
- package_morphology = MorphologyClassifier.new(package:, phase_seed: package_seed).call
32
- package_morphologies << [package_morphology.fetch("family"), *package_morphology.fetch("knobs")]
67
+ namespaces = build_namespaces(snapshot, random)
68
+ packages = build_packages(snapshot, random)
69
+ dependencies = build_dependencies(snapshot, packages, namespaces, random)
70
+
71
+ {
72
+ "schema" => SCHEMA,
73
+ "projectName" => snapshot.fetch("project_name"),
74
+ "morphology" => morphology_row(MorphologyClassifier.new(snapshot).call),
75
+ "totals" => {
76
+ "namespaces" => namespaces.rows.length,
77
+ "packages" => packages.rows.length,
78
+ "dependencyStars" => dependencies.rows.length,
79
+ },
80
+ "domains" => signal_domains(namespaces.rows, snapshot.fetch("dependency_signal_maxima")),
81
+ "categoryStats" => snapshot.fetch("category_stats"),
82
+ "namespaceNames" => namespaces.names,
83
+ "namespaces" => namespaces.rows,
84
+ "constantReferenceLinks" => build_constant_reference_links(
85
+ namespaces.reference_rows,
86
+ namespaces.draw_positions,
87
+ dependencies.index,
88
+ ),
89
+ "packageNames" => packages.names,
90
+ "packages" => packages.rows,
91
+ "packageMorphologies" => packages.morphologies,
92
+ "dependencySystems" => packages.systems,
93
+ "dependencyStars" => dependencies.rows,
94
+ "dependencyWarnings" => allowed_dependency_warnings(snapshot),
95
+ "warningCounts" => snapshot.fetch("warning_counts"),
96
+ }
97
+ end
98
+
99
+ private
100
+
101
+ #: (Hash[String, untyped], Random) -> Namespaces
102
+ def build_namespaces(snapshot, random)
103
+ rows = snapshot.fetch("namespaces")
104
+ names = snapshot.fetch("namespace_names")
105
+ order = (0...rows.length).to_a.shuffle(random: random)
106
+ Namespaces.new(
107
+ order: order,
108
+ rows: order.map { |old_index| [random.rand(0..0xffff_ffff), *rows.fetch(old_index)] },
109
+ names: order.map { |old_index| names.fetch(old_index) },
110
+ reference_rows: valid_constant_reference_rows(snapshot),
111
+ )
112
+ end
113
+
114
+ #: (Hash[String, untyped], Random) -> Packages
115
+ def build_packages(snapshot, random)
116
+ snapshot_packages = snapshot.fetch("packages")
117
+ order = (0...snapshot_packages.length).to_a.shuffle(random: random)
118
+ systems, system_index = build_dependency_systems(snapshot, order.each_with_index.to_h)
119
+ morphologies = []
120
+
121
+ rows = order.map do |old_index|
122
+ package = snapshot_packages.fetch(old_index)
123
+ seed = random.rand(0..0xffff_ffff)
124
+ morphologies << morphology_row(MorphologyClassifier.new(package: package, phase_seed: seed).call)
33
125
  [
34
- package_seed,
126
+ seed,
35
127
  package.fetch("role"),
36
128
  package.fetch("location"),
37
- declaration_count,
129
+ package.fetch("declarations").length,
38
130
  *package.fetch("ruby_counts"),
39
- dependency_system_index.fetch(old_index, -1),
131
+ system_index.fetch(old_index, -1),
40
132
  ]
41
133
  end
42
- 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")
45
- end
46
- dependencies = []
47
- package_order.each do |old_index|
134
+
135
+ Packages.new(
136
+ order: order,
137
+ rows: rows,
138
+ names: order.map { |old_index| snapshot_packages.fetch(old_index).fetch("name") },
139
+ morphologies: morphologies,
140
+ systems: systems,
141
+ )
142
+ end
143
+
144
+ # Dependency stars are emitted package by package in shuffled order, so a
145
+ # snapshot ordinal is only recoverable for the rows travel actually targets.
146
+ # Those rows are identified before the shuffle and tracked by object
147
+ # identity, since equal declaration rows are common and interchangeable.
148
+ #: (Hash[String, untyped], Packages, Namespaces, Random) -> Dependencies
149
+ def build_dependencies(snapshot, packages, namespaces, random)
150
+ targeted = travel_target_rows(snapshot, namespaces.referenced_dependency_ordinals)
151
+ rows = []
152
+ index = {}
153
+ packages.order.each_with_index do |old_index, new_index|
48
154
  package = snapshot.fetch("packages").fetch(old_index)
49
- declarations = package.fetch("declarations").sort.shuffle(random: random)
50
- declarations.each do |declaration|
51
- dependencies << [
52
- random.rand(0..0xffff_ffff),
53
- package_index.fetch(old_index),
54
- *declaration.drop(1),
55
- ]
155
+ package.fetch("declarations").sort.shuffle(random: random).each do |declaration|
156
+ original_ordinal = targeted[declaration]
157
+ index[original_ordinal] = rows.length if original_ordinal
158
+ rows << [random.rand(0..0xffff_ffff), new_index, *declaration.drop(1)]
56
159
  end
57
160
  end
58
- {
59
- "schema" => "rubylens.art.v10",
60
- "projectName" => snapshot.fetch("project_name"),
61
- "morphology" => morphology,
62
- "totals" => {
63
- "namespaces" => namespaces.length,
64
- "packages" => packages.length,
65
- "dependencyStars" => indexed_dependency_count,
66
- "renderedDependencyStars" => dependencies.length,
67
- },
68
- "domains" => signal_domains(namespaces, snapshot.fetch("dependency_signal_maxima")),
69
- "componentCounts" => snapshot.fetch("components"),
70
- "categoryStats" => snapshot.fetch("category_stats"),
71
- "namespaceNames" => namespace_names,
72
- "namespaces" => namespaces,
73
- "packageNames" => package_names,
74
- "packages" => packages,
75
- "packageMorphologies" => package_morphologies,
76
- "dependencySystems" => dependency_systems,
77
- "dependencyStars" => dependencies,
78
- "dependencyWarnings" => snapshot.fetch("dependency_warnings", []).filter_map do |warning|
79
- name = warning.fetch("name")
80
- reason = warning.fetch("reason")
81
- next unless name.is_a?(String) && DependencyWarning::NAME_PATTERN.match?(name)
82
- next unless reason.is_a?(String) && DependencyWarning::ALLOWED_REASONS.include?(reason)
83
-
84
- { "name" => name, "reason" => reason }
85
- end,
86
- "warningCounts" => snapshot.fetch("warning_counts"),
87
- }
161
+ Dependencies.new(rows: rows, index: index)
88
162
  end
89
163
 
90
- private
164
+ #: (Hash[String, untyped], Array[Integer]) -> Hash[Array[Integer], Integer]
165
+ def travel_target_rows(snapshot, dependency_ordinals)
166
+ offsets = []
167
+ total = 0
168
+ snapshot.fetch("packages").each do |package|
169
+ offsets << total
170
+ total += package.fetch("declarations").length
171
+ end
172
+
173
+ targeted = {}.compare_by_identity
174
+ dependency_ordinals.each do |ordinal|
175
+ next if ordinal.negative? || ordinal >= total
91
176
 
177
+ next_package = offsets.bsearch_index { |offset| offset > ordinal }
178
+ package_index = (next_package || offsets.length) - 1
179
+ declarations = snapshot.fetch("packages").fetch(package_index).fetch("declarations")
180
+ targeted[declarations.fetch(ordinal - offsets.fetch(package_index))] = ordinal
181
+ end
182
+ targeted
183
+ end
184
+
185
+ #: (Hash[String, untyped]) -> Array[Integer]
186
+ def morphology_row(morphology)
187
+ [morphology.fetch("family"), *morphology.fetch("knobs")]
188
+ end
189
+
190
+ # Warnings reach the artifact only if they match the closed vocabulary, so a
191
+ # tampered snapshot cannot smuggle arbitrary text into a shareable page.
192
+ #: (Hash[String, untyped]) -> Array[Hash[String, String]]
193
+ def allowed_dependency_warnings(snapshot)
194
+ snapshot.fetch("dependency_warnings", []).filter_map do |warning|
195
+ name = warning.fetch("name")
196
+ reason = warning.fetch("reason")
197
+ next unless name.is_a?(String) && DependencyWarning::NAME_PATTERN.match?(name)
198
+ next unless reason.is_a?(String) && DependencyWarning::ALLOWED_REASONS.include?(reason)
199
+
200
+ { "name" => name, "reason" => reason }
201
+ end
202
+ end
203
+
204
+ #: (Hash[String, untyped]) -> Array[[Integer, Integer]]
205
+ def valid_constant_reference_rows(snapshot)
206
+ rows = snapshot.fetch("constant_reference_links", [])
207
+ return [] unless rows.is_a?(Array)
208
+
209
+ rows.select do |row|
210
+ row.is_a?(Array) && row.length == 2 && row.all?(Integer) && row.none?(&:negative?)
211
+ end
212
+ end
213
+
214
+ #: (Array[[Integer, Integer]], Array[Integer], Hash[Integer, Integer]) -> Array[[Integer, Integer]]
215
+ def build_constant_reference_links(rows, namespace_index, dependency_index)
216
+ snapshot_namespace_count = namespace_index.length
217
+ candidates = rows.filter_map do |row|
218
+ referring_index, referenced_index = row
219
+ remapped_referring_index = namespace_index[referring_index]
220
+ remapped_referenced_index = if referenced_index < snapshot_namespace_count
221
+ namespace_index[referenced_index]
222
+ else
223
+ dependency = dependency_index[referenced_index - snapshot_namespace_count]
224
+ snapshot_namespace_count + dependency if dependency
225
+ end
226
+ next if remapped_referring_index.nil? || remapped_referenced_index.nil?
227
+
228
+ [remapped_referring_index, remapped_referenced_index]
229
+ end
230
+
231
+ candidates.shuffle(random: Random.new(@seed ^ 0xC057_A17E))
232
+ end
233
+
234
+ #: (Hash[String, untyped], Hash[Integer, Integer]) -> [Array[Array[Integer]], Hash[Integer, Integer]]
92
235
  def build_dependency_systems(snapshot, package_index)
93
236
  system_random = Random.new(@seed ^ 0xD3E5_157E)
94
237
  package_system_index = {}
@@ -104,17 +247,16 @@ module RubyLens
104
247
  [systems, package_system_index]
105
248
  end
106
249
 
107
- def signal_domains(namespaces, dependency_maxima)
108
- namespace_columns = [4, 5, 6, 7, 8, 9]
109
- namespace_domains = namespace_columns.map { |column| maximum(namespaces, column) }
250
+ # Each signal is scaled against the larger of its workspace and dependency
251
+ # maxima, so the two populations stay comparable in the same scene.
252
+ #: (Array[Array[Integer]], Array[Integer]) -> Hash[String, Integer]
253
+ def signal_domains(namespace_rows, dependency_maxima)
254
+ namespace_domains = NAMESPACE_SIGNAL_COLUMNS.map do |column|
255
+ namespace_rows.map { |row| row[column].to_i }.max || 0
256
+ end
110
257
  SIGNAL_FIELDS.each_with_index.to_h do |field, index|
111
258
  [field, [namespace_domains[index], dependency_maxima[index]].max]
112
259
  end
113
260
  end
114
-
115
- def maximum(rows, column)
116
- rows.map { |row| row[column].to_i }.max || 0
117
- end
118
-
119
261
  end
120
262
  end
@@ -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