codebase-onboarder 0.5.0 → 0.6.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.
- package/README.md +23 -2
- package/cli/explorer/advanced.js +457 -0
- package/cli/explorer/app.js +107 -11
- package/cli/explorer/commands.js +186 -14
- package/cli/explorer/github.js +75 -0
- package/cli/explorer/graphs.js +178 -0
- package/cli/explorer/session.js +95 -5
- package/cli/explorer/views.js +216 -115
- package/cli/explorer/wrap.js +49 -0
- package/cli/main.js +49 -5
- package/cli/ui.js +31 -2
- package/package.json +1 -1
- package/public/js/about.js +20 -7
- package/server/gitRemote.js +61 -0
- package/server/layout.js +41 -14
- package/shared/analyzer/github.js +81 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Rendering primitives that the terminal can do and a browser cannot afford to.
|
|
2
|
+
//
|
|
3
|
+
// The site's coupling matrix is a canvas heat grid and its mind map is SVG. In a
|
|
4
|
+
// terminal the honest translation of "a heat grid" is block characters, and the
|
|
5
|
+
// honest translation of "a dependency tree" is the one everybody already knows
|
|
6
|
+
// how to read: an indented tree with arrows. Neither is a worse version of the
|
|
7
|
+
// web view — they are the terminal's own idiom, drawn from the same numbers.
|
|
8
|
+
|
|
9
|
+
import { fit, termWidth } from '../ui.js';
|
|
10
|
+
import { width } from '../../server/layout.js';
|
|
11
|
+
|
|
12
|
+
// Five ramp steps. Unicode 2591–2588, with an ASCII floor for terminals that
|
|
13
|
+
// cannot show them. Density reads as "how dark", which survives being printed in
|
|
14
|
+
// black and white, redirected to a file, or read by someone who cannot tell the
|
|
15
|
+
// ramp apart — the thing a color-only heatmap cannot promise.
|
|
16
|
+
const RAMP_UNICODE = [' ', '░', '▒', '▓', '█'];
|
|
17
|
+
const RAMP_ASCII = ['.', ':', '+', '*', '#'];
|
|
18
|
+
|
|
19
|
+
// A row that is `marker + value + tail`, where the tail is the first thing to go
|
|
20
|
+
// when the terminal is too narrow to hold it.
|
|
21
|
+
//
|
|
22
|
+
// This is the same mistake in five places — health risks, security findings,
|
|
23
|
+
// entry points, symbol rows, and layer notes — and fixing each by hand means the
|
|
24
|
+
// sixth gets it wrong too. The rule in one place: if the tail leaves less than
|
|
25
|
+
// `MIN_PATH` cells for the value, drop the tail and give the value the rest.
|
|
26
|
+
//
|
|
27
|
+
// `marker` may be styled, so its width is measured with `width()` (which strips
|
|
28
|
+
// ANSI) rather than `.length`. Measuring a styled string counts escape-sequence
|
|
29
|
+
// characters as if they occupied cells, which is how a "fitted" row still
|
|
30
|
+
// overflows by the length of the color codes.
|
|
31
|
+
const MIN_PATH = 8;
|
|
32
|
+
|
|
33
|
+
// `pad` is the indent *inside* the width budget, not a prefix glued on
|
|
34
|
+
// afterwards. An earlier version returned `marker + value` and let the caller
|
|
35
|
+
// prepend `' '`, which is four cells the helper could not see — so every row
|
|
36
|
+
// came out four cells too wide, which is exactly the overflow this exists to
|
|
37
|
+
// prevent. The indent is part of the row now.
|
|
38
|
+
//
|
|
39
|
+
// The value always gets at least one cell and never more than the terminal has
|
|
40
|
+
// left, even when the marker alone is wider than the terminal. That last case is
|
|
41
|
+
// the one a `Math.max(1, …)` floor gets wrong: a floor of 1 plus an
|
|
42
|
+
// over-wide marker is still an overflow, so the value is clamped by `w` itself
|
|
43
|
+
// and the marker is fitted too.
|
|
44
|
+
export function labelled(marker, value, tail = '', { pad = 0 } = {}) {
|
|
45
|
+
const w = termWidth();
|
|
46
|
+
const indent = ' '.repeat(Math.max(0, Math.min(pad, Math.max(0, w - 1))));
|
|
47
|
+
const markerRoom = Math.max(0, w - width(indent) - 1);
|
|
48
|
+
const head = fit(marker, markerRoom);
|
|
49
|
+
const lead = width(indent) + width(head);
|
|
50
|
+
const withTail = w - lead - width(tail);
|
|
51
|
+
if (tail && withTail > MIN_PATH) return indent + head + fit(value, withTail) + tail;
|
|
52
|
+
return indent + head + fit(value, Math.max(0, w - lead));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const glyphs = () => (process.platform === 'win32' && !process.env.WT_SESSION ? RAMP_ASCII : RAMP_UNICODE);
|
|
56
|
+
|
|
57
|
+
// The strongest signal a number gets, as a fraction of the largest, in five
|
|
58
|
+
// steps. Zero is always the blank cell rather than the faintest mark: "nothing"
|
|
59
|
+
// and "a little" are different facts, and a heat grid that gives zero a speck of
|
|
60
|
+
// ink makes every map look busier than it is.
|
|
61
|
+
//
|
|
62
|
+
// Returns the *character*, always a string — an early version returned the
|
|
63
|
+
// number 0 for "empty", which then blew up on `.padEnd()` the first time a
|
|
64
|
+
// genuinely-zero cell was drawn, i.e. only on repos with real structure.
|
|
65
|
+
function step(value, max) {
|
|
66
|
+
const ramp = glyphs();
|
|
67
|
+
if (!value || max <= 0) return ramp[0];
|
|
68
|
+
const i = Math.min(ramp.length - 1, Math.max(1, Math.ceil((value / max) * (ramp.length - 1))));
|
|
69
|
+
return ramp[i];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { step };
|
|
73
|
+
|
|
74
|
+
// A horizontal bar, sized as a fraction of the largest value. Exported so every
|
|
75
|
+
// ranked list draws its bars identically instead of each view re-deriving the
|
|
76
|
+
// scale and drifting.
|
|
77
|
+
export function bar(value, max, room) {
|
|
78
|
+
if (room <= 0 || !value || max <= 0) return '';
|
|
79
|
+
const ramp = glyphs();
|
|
80
|
+
const full = Math.max(1, Math.round((value / max) * room));
|
|
81
|
+
return ramp[ramp.length - 1].repeat(Math.min(room, full));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A vertical bar chart, because some questions are about magnitude across
|
|
85
|
+
// categories and reading them off a horizontal list is harder than it needs to
|
|
86
|
+
// be. Columns collapse to a single row when the terminal is too narrow to hold
|
|
87
|
+
// them, rather than drawing a squeezed, unreadable grid.
|
|
88
|
+
export function columns(items, { room = 40, rows = 10 } = {}) {
|
|
89
|
+
if (!items.length) return [];
|
|
90
|
+
const width = Math.max(1, Math.min(items.length, room));
|
|
91
|
+
const stepSize = Math.ceil(items.length / width);
|
|
92
|
+
const shown = stepSize > 1 ? items.filter((_, i) => i % stepSize === 0).slice(0, width) : items;
|
|
93
|
+
const top = Math.max(...shown.map((i) => i.value)) || 1;
|
|
94
|
+
const solid = glyphs()[glyphs().length - 1];
|
|
95
|
+
const out = [];
|
|
96
|
+
for (let level = rows; level >= 1; level--) {
|
|
97
|
+
const threshold = (level / rows) * top;
|
|
98
|
+
const line = shown.map((i) => (i.value >= threshold ? solid : ' ')).join('');
|
|
99
|
+
if (line.trim()) out.push(' ' + line);
|
|
100
|
+
}
|
|
101
|
+
out.push(' ' + shown.map((i) => String(i.label).slice(0, 1)).join(''));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The folder-to-folder coupling grid, as a matrix. Row label, then one cell per
|
|
106
|
+
// folder, heaviest traffic darkest. Columns are addressed by letter so a cell
|
|
107
|
+
// can be named rather than counted. The diagonal is blank: a folder's traffic
|
|
108
|
+
// with itself is not coupling, it is just being that folder.
|
|
109
|
+
export function couplingGrid(repo, { maxFolders = 8, columns: cols } = {}) {
|
|
110
|
+
const matrix = repo.coupling;
|
|
111
|
+
if (!matrix || !matrix.folders.length) return [];
|
|
112
|
+
const folders = matrix.folders.slice(0, maxFolders);
|
|
113
|
+
const w = cols || termWidth();
|
|
114
|
+
const labelRoom = Math.min(18, Math.max(...folders.map((f) => f.length)) + 1);
|
|
115
|
+
const cellRoom = Math.max(1, Math.floor((w - labelRoom - 4) / folders.length));
|
|
116
|
+
const cellW = Math.max(1, Math.min(2, cellRoom));
|
|
117
|
+
|
|
118
|
+
const head = ' '.repeat(labelRoom) + folders.map((_, i) => String.fromCharCode(65 + i).padEnd(cellW)).join('');
|
|
119
|
+
// The rule is sized to the grid but never past the terminal, so a very narrow
|
|
120
|
+
// window gets a short rule rather than a wrapped one.
|
|
121
|
+
const rule = '─'.repeat(Math.max(1, Math.min(labelRoom + folders.length * cellW, Math.max(1, w - 2))));
|
|
122
|
+
const out = [' ' + fit(head, w - 2), ' ' + rule];
|
|
123
|
+
|
|
124
|
+
folders.forEach((from, r) => {
|
|
125
|
+
let line = fit(from, labelRoom);
|
|
126
|
+
folders.forEach((to, c) => {
|
|
127
|
+
if (r === c) {
|
|
128
|
+
line += ' '.repeat(cellW);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const n = matrix.counts.get(from + '->' + to) || 0;
|
|
132
|
+
line += step(n, matrix.max).padEnd(cellW);
|
|
133
|
+
});
|
|
134
|
+
out.push(' ' + fit(line, w - 2));
|
|
135
|
+
});
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A dependency tree rooted at a file, drawn as a tree with the one piece of
|
|
140
|
+
// information that makes a tree readable — which way each edge points. This is
|
|
141
|
+
// the terminal's answer to the site's force-directed graph: a browser can show
|
|
142
|
+
// every edge at once, a terminal cannot, so it shows the part that answers "what
|
|
143
|
+
// breaks if I break this".
|
|
144
|
+
//
|
|
145
|
+
// The `seen` set makes the walk terminate on a cyclic graph — without it, a
|
|
146
|
+
// two-file import cycle would recurse until the stack gave out. The cost is that
|
|
147
|
+
// a file reached by two different paths is drawn only under the first, which is
|
|
148
|
+
// the right trade for a bounded tree.
|
|
149
|
+
export function depTree(repo, rootPath, { depth = 2, direction = 'both', room } = {}) {
|
|
150
|
+
const w = room || termWidth();
|
|
151
|
+
const out = [];
|
|
152
|
+
const seen = new Set([rootPath]);
|
|
153
|
+
|
|
154
|
+
const walk = (path, prefix, left, arrow) => {
|
|
155
|
+
out.push(fit(prefix + arrow + ' ' + path, w - 2));
|
|
156
|
+
if (left <= 0) return;
|
|
157
|
+
|
|
158
|
+
const kids = [];
|
|
159
|
+
if (direction !== 'up') {
|
|
160
|
+
for (const child of repo.facts.importsOf[path] || []) {
|
|
161
|
+
if (!seen.has(child)) kids.push({ path: child, arrow: '→' });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (direction !== 'down') {
|
|
165
|
+
for (const parent of repo.facts.importers[path] || []) {
|
|
166
|
+
if (!seen.has(parent)) kids.push({ path: parent, arrow: '←' });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
kids.forEach((k, i) => {
|
|
170
|
+
if (seen.has(k.path)) return;
|
|
171
|
+
seen.add(k.path);
|
|
172
|
+
walk(k.path, prefix + (i === kids.length - 1 ? ' ' : '│ '), left - 1, k.arrow);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
walk(rootPath, '', depth, '●');
|
|
177
|
+
return out;
|
|
178
|
+
}
|
package/cli/explorer/session.js
CHANGED
|
@@ -17,17 +17,82 @@ import { nodeFileSource } from '../../server/fileSourceNode.js';
|
|
|
17
17
|
import { buildSearchIndex } from '../../server/searchIndex.js';
|
|
18
18
|
import { searchDocuments } from '../../server/apiSearch.js';
|
|
19
19
|
import { expandHome, resolveInside } from '../../server/paths.js';
|
|
20
|
+
import { assertGitUrl, cloneRepo, removeClone, repoNameFromUrl } from '../../server/gitClone.js';
|
|
21
|
+
import { gitRemoteOrigin } from '../../server/gitRemote.js';
|
|
20
22
|
import { scanRepo } from '../../shared/analyzer/scan.js';
|
|
21
23
|
import { detectManifest } from '../../shared/analyzer/services.js';
|
|
22
24
|
import { computeFacts, roleOf } from '../../shared/analyzer/graph.js';
|
|
23
25
|
import { analyzeHealth } from '../../shared/analyzer/health.js';
|
|
24
|
-
import { computeLayers, detectPatterns } from '../../shared/analyzer/patterns.js';
|
|
26
|
+
import { computeLayers, detectPatterns, couplingMatrix } from '../../shared/analyzer/patterns.js';
|
|
25
27
|
import { summarizeSecurity } from '../../shared/analyzer/security.js';
|
|
26
28
|
import { analyzeStack } from '../../shared/analyzer/stack.js';
|
|
27
29
|
import { buildTourStops } from '../../shared/analyzer/tour.js';
|
|
28
30
|
import { explainFile, explainFolder, explainOverview } from '../../shared/analyzer/explainLocal.js';
|
|
29
31
|
import { languageLabel } from '../../shared/analyzer/languages/index.js';
|
|
30
32
|
|
|
33
|
+
// Does this argument name a repository to fetch, rather than a folder on this
|
|
34
|
+
// machine? The check is deliberately narrow: only the URL shapes git itself
|
|
35
|
+
// accepts, so a folder that happens to be called `ssh://stuff` cannot exist on a
|
|
36
|
+
// filesystem anyway, while `owner/repo` is NOT accepted — too many real folders
|
|
37
|
+
// are named like that (`src/server`, `app/models`) and silently cloning
|
|
38
|
+
// `github.com/src/server` because someone typed `cd src/server` would be a
|
|
39
|
+
// genuinely bad failure. The shorthand, if it is wanted, has to be asked for.
|
|
40
|
+
export function isGitUrl(target) {
|
|
41
|
+
return /^(https?:\/\/|git@|ssh:\/\/)/.test(String(target || '').trim());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Open a local folder or clone a remote one — the same three ways in the web
|
|
45
|
+
// app's landing page offers, narrowed to the two that make sense for a command
|
|
46
|
+
// line. A clone is left in place when the session ends; `closeRemote` removes it,
|
|
47
|
+
// and the session calls that on `cd` and on exit, so nothing is orphaned in the
|
|
48
|
+
// temp dir.
|
|
49
|
+
export async function openRepoOrClone(target, { onProgress } = {}) {
|
|
50
|
+
const raw = String(target || '').trim();
|
|
51
|
+
if (!isGitUrl(raw)) return openRepo(raw, { onProgress });
|
|
52
|
+
|
|
53
|
+
const url = assertGitUrl(raw);
|
|
54
|
+
if (onProgress) onProgress({ phase: 'clone', url });
|
|
55
|
+
const clone = await cloneRepo(url);
|
|
56
|
+
try {
|
|
57
|
+
const repo = await openRepo(clone.dir, { onProgress });
|
|
58
|
+
// The repo's own name is what a person recognizes; the temp path is kept so
|
|
59
|
+
// `about` can still print a path someone could go and look at.
|
|
60
|
+
return {
|
|
61
|
+
...repo,
|
|
62
|
+
name: repoNameFromUrl(url) || repo.name,
|
|
63
|
+
gitUrl: url,
|
|
64
|
+
tempId: path.basename(clone.dir),
|
|
65
|
+
cloneDir: clone.dir,
|
|
66
|
+
};
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// A clone that was never loaded has no session to clean it up later, so it
|
|
69
|
+
// has to go now or it is a temp directory nobody owns.
|
|
70
|
+
await removeClone(clone.dir);
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The URL this repo should be asked about on GitHub, or null.
|
|
76
|
+
//
|
|
77
|
+
// A repo this session cloned knows its own URL. A repo someone was standing in
|
|
78
|
+
// does not, and git does. Asking git here rather than in the `github` command
|
|
79
|
+
// keeps the answer on the repo object, so every consumer gets it the same way.
|
|
80
|
+
export async function remoteUrlFor(repo) {
|
|
81
|
+
if (repo.gitUrl) return repo.gitUrl;
|
|
82
|
+
if (repo.remoteChecked) return null;
|
|
83
|
+
repo.remoteChecked = true;
|
|
84
|
+
const found = await gitRemoteOrigin(repo.root);
|
|
85
|
+
if (found.ok) repo.gitUrl = found.url;
|
|
86
|
+
return repo.gitUrl || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Drop a temp clone, if this repo is one. Safe to call on a local repo: it
|
|
90
|
+
// checks the shape of the directory rather than trusting a flag, so a bug
|
|
91
|
+
// elsewhere cannot turn "clean up" into "delete the folder the user is in".
|
|
92
|
+
export async function closeRemote(repo) {
|
|
93
|
+
if (repo?.cloneDir) await removeClone(repo.cloneDir);
|
|
94
|
+
}
|
|
95
|
+
|
|
31
96
|
// Scan a directory and return the loaded repo, or throw an Error whose message
|
|
32
97
|
// is safe to print straight to the user. `target` may be `~`, a relative path,
|
|
33
98
|
// or anything `expandHome` understands; it defaults to the current directory.
|
|
@@ -45,7 +110,9 @@ export async function openRepo(target = '.', { onProgress } = {}) {
|
|
|
45
110
|
|
|
46
111
|
// The layers projection is an input to patterns, not a parallel view of the
|
|
47
112
|
// same thing — `detectPatterns` reads the layer depths to place a file in the
|
|
48
|
-
// architecture. Compute once, share.
|
|
113
|
+
// architecture. Compute once, share. The coupling matrix is the same kind of
|
|
114
|
+
// thing: a projection of `facts.folderEdges` that both the site and the
|
|
115
|
+
// terminal's heat grid read, so it is computed once here rather than per view.
|
|
49
116
|
const layers = computeLayers(scan, facts);
|
|
50
117
|
|
|
51
118
|
return {
|
|
@@ -56,6 +123,7 @@ export async function openRepo(target = '.', { onProgress } = {}) {
|
|
|
56
123
|
manifest,
|
|
57
124
|
searchIndex,
|
|
58
125
|
layers,
|
|
126
|
+
coupling: couplingMatrix(scan, facts),
|
|
59
127
|
health: analyzeHealth(scan, facts),
|
|
60
128
|
patterns: detectPatterns(scan, facts, manifest, layers),
|
|
61
129
|
security: summarizeSecurity(scan),
|
|
@@ -83,6 +151,12 @@ export function resolveTarget(repo, typed) {
|
|
|
83
151
|
const raw = String(typed || '').trim().replace(/^\.\//, '');
|
|
84
152
|
if (!raw) return { error: 'Name a file or folder.' };
|
|
85
153
|
|
|
154
|
+
// `.` and `./` are the root, which is a real folder and a common thing to type
|
|
155
|
+
// (`tree .`, `explain .`). Without this they fell through to the substring
|
|
156
|
+
// match and offered eight unrelated files as "did you mean", which is worse
|
|
157
|
+
// than useless — it is actively misleading. The root folder's path is `''`.
|
|
158
|
+
if (raw === '.' || raw === './') return { folder: ROOT_FOLDER };
|
|
159
|
+
|
|
86
160
|
const files = repo.scan.files;
|
|
87
161
|
if (!repo.byPath) repo.byPath = new Map(files.map((f) => [f.path, f]));
|
|
88
162
|
if (repo.byPath.has(raw)) return { file: repo.byPath.get(raw) };
|
|
@@ -107,11 +181,16 @@ export function resolveTarget(repo, typed) {
|
|
|
107
181
|
return { error: 'Nothing in this repo matches "' + raw + '".' };
|
|
108
182
|
}
|
|
109
183
|
|
|
184
|
+
// The root folder is a real answer, not a sentinel: `tree .` and `explain .`
|
|
185
|
+
// both need one, and it carries the same fields `resolveTarget` returns for any
|
|
186
|
+
// other folder so the callers need no special case.
|
|
187
|
+
const ROOT_FOLDER = Object.freeze({ name: '', path: '', loc: 0, comment: 0, blank: 0, size: 0, langs: {} });
|
|
188
|
+
|
|
110
189
|
function folderFor(repo, typed) {
|
|
111
190
|
const clean = typed.replace(/\/+$/, '');
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
191
|
+
if (clean === '') return ROOT_FOLDER;
|
|
192
|
+
const exact = repo.scan.folders.find((d) => d.path === clean);
|
|
193
|
+
if (exact) return exact;
|
|
115
194
|
const matches = repo.scan.folders.filter((d) => d.path.endsWith('/' + clean) || d.name === clean);
|
|
116
195
|
return matches.length === 1 ? matches[0] : null;
|
|
117
196
|
}
|
|
@@ -134,6 +213,17 @@ export function searchRepo(repo, query, { limit = 12 } = {}) {
|
|
|
134
213
|
return searchDocuments(repo.searchIndex, query, { limit });
|
|
135
214
|
}
|
|
136
215
|
|
|
216
|
+
// A number a person typed, or nothing. `show f 1.5 2.5` used to print
|
|
217
|
+
// `1.5–3 of 344`: `Number('1.5')` passed the `|| 1` fallback, the label
|
|
218
|
+
// interpolated the fraction, and only `slice()` coerced it. A line range is
|
|
219
|
+
// either a whole number of lines or it is a mistake, and a mistake gets a
|
|
220
|
+
// message rather than a wrong heading.
|
|
221
|
+
export function lineNumber(value, fallback) {
|
|
222
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
223
|
+
const n = Number(value);
|
|
224
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
137
227
|
export function explainRepoFile(repo, file) {
|
|
138
228
|
return explainFile(file.path, file, repo.facts);
|
|
139
229
|
}
|