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.
- checksums.yaml +4 -4
- data/README.md +67 -39
- data/assets/runtime/report.js +1607 -251
- data/assets/shells/report.html +9 -7
- data/assets/shells/showcase.html +1 -0
- data/assets/styles/report.css +51 -25
- data/assets/styles/showcase.css +3 -0
- data/lib/rubylens/art_model_builder.rb +214 -72
- data/lib/rubylens/artifact_marker.rb +17 -0
- data/lib/rubylens/atomic_output.rb +27 -0
- data/lib/rubylens/cli.rb +53 -36
- data/lib/rubylens/clip/chrome_page.rb +183 -0
- data/lib/rubylens/clip/deadline_io.rb +28 -0
- data/lib/rubylens/clip/renderer.rb +113 -0
- data/lib/rubylens/clip/toolchain.rb +82 -0
- data/lib/rubylens/clip/web_socket_channel.rb +116 -0
- data/lib/rubylens/clip_generator.rb +102 -0
- data/lib/rubylens/default_output.rb +24 -0
- data/lib/rubylens/generator.rb +9 -10
- data/lib/rubylens/git_repository.rb +13 -7
- data/lib/rubylens/index/constant_reference_collector.rb +150 -0
- data/lib/rubylens/index/declaration_collector.rb +288 -0
- data/lib/rubylens/index/git_package_source.rb +202 -0
- data/lib/rubylens/index/location_index.rb +103 -0
- data/lib/rubylens/index/manifest.rb +62 -157
- data/lib/rubylens/index/rspec_extractor.rb +12 -10
- data/lib/rubylens/index/rubydex_adapter.rb +111 -301
- data/lib/rubylens/index/source_path.rb +12 -11
- data/lib/rubylens/model/dependency_aggregation.rb +11 -7
- data/lib/rubylens/morphology_classifier.rb +52 -12
- data/lib/rubylens/report_writer.rb +5 -20
- data/lib/rubylens/showcase_generator.rb +5 -9
- data/lib/rubylens/showcase_model.rb +12 -14
- data/lib/rubylens/showcase_writer.rb +0 -6
- data/lib/rubylens/version.rb +1 -1
- data/lib/rubylens.rb +7 -0
- metadata +16 -3
data/assets/shells/report.html
CHANGED
|
@@ -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><
|
|
26
|
-
<div class="panel-header-actions"><button type="button" id="panel-toggle" aria-expanded="true" aria-controls="panel-body">
|
|
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">
|
|
31
|
-
<input type="search" id="explorer-search" placeholder="Class, module, or gem
|
|
32
|
-
<p class="search-status" id="search-status" role="status" aria-live="polite"
|
|
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">
|
|
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>
|
data/assets/shells/showcase.html
CHANGED
|
@@ -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>
|
data/assets/styles/report.css
CHANGED
|
@@ -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, .
|
|
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:
|
|
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-
|
|
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:
|
|
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
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.section
|
|
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: #
|
|
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:
|
|
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(
|
|
61
|
-
.visibility { display: inline-flex; align-items: center; gap:
|
|
62
|
-
.visibility input { width:
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
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"] {
|
|
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 {
|
|
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:
|
|
123
|
-
.toolbar button { min-height: 40px; padding-inline:
|
|
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) {
|
|
153
|
+
@media (prefers-reduced-motion: reduce) { summary::after { transition: none; } }
|
data/assets/styles/showcase.css
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
126
|
+
seed,
|
|
35
127
|
package.fetch("role"),
|
|
36
128
|
package.fetch("location"),
|
|
37
|
-
|
|
129
|
+
package.fetch("declarations").length,
|
|
38
130
|
*package.fetch("ruby_counts"),
|
|
39
|
-
|
|
131
|
+
system_index.fetch(old_index, -1),
|
|
40
132
|
]
|
|
41
133
|
end
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|