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
package/cli/explorer/views.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// every other panel in this project is, including after a resize.
|
|
12
12
|
|
|
13
13
|
import { bold, cyan, dim, ok, warn, bad, panel, row, fit, termWidth } from '../ui.js';
|
|
14
|
+
import { labelled } from './graphs.js';
|
|
14
15
|
import { fileFacts, readRepoFile, resolveTarget, searchRepo, explainRepoFile, explainRepoFolder, explainRepoOverview } from './session.js';
|
|
15
16
|
import { scanCaveats } from '../../shared/analyzer/explainLocal.js';
|
|
17
|
+
import { wrapText } from './wrap.js';
|
|
16
18
|
|
|
17
19
|
const MAX = 200; // a ceiling on any list, so one command cannot flood a terminal
|
|
18
20
|
|
|
@@ -27,7 +29,12 @@ const GLYPHS = process.platform === 'win32' && !process.env.WT_SESSION
|
|
|
27
29
|
// — it is the first thing printed, so it sets the impression of everything else.
|
|
28
30
|
// On a narrow terminal it drops hints rather than wrapping.
|
|
29
31
|
function hintBar(hints) {
|
|
30
|
-
|
|
32
|
+
// The room is the terminal minus the 2-space lead. The `Math.max(8, …)` that
|
|
33
|
+
// used to be here was a floor *above* the available width on an 8-column
|
|
34
|
+
// terminal, which guarantees an overflow — the exact opposite of what a
|
|
35
|
+
// hint bar is for. Below the width of one hint, the hint is cut to fit
|
|
36
|
+
// rather than allowed to wrap.
|
|
37
|
+
const room = Math.max(1, termWidth() - 2);
|
|
31
38
|
const parts = [];
|
|
32
39
|
for (const h of hints) {
|
|
33
40
|
const next = parts.length ? parts.join(' · ') + ' · ' + h : h;
|
|
@@ -38,41 +45,7 @@ function hintBar(hints) {
|
|
|
38
45
|
return dim(' ' + line);
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
//
|
|
42
|
-
// lines. Applied *before* painting, so the wrap never has to understand ANSI —
|
|
43
|
-
// the styled string is built from already-wrapped plain text.
|
|
44
|
-
//
|
|
45
|
-
// This is what keeps a long caveat or a paragraph of explanation from running
|
|
46
|
-
// off the right edge. The engine's own messages are written as sentences for a
|
|
47
|
-
// browser panel; a terminal needs them folded.
|
|
48
|
-
function wrapText(text, indent = ' ', room = termWidth() - indent.length) {
|
|
49
|
-
const out = [];
|
|
50
|
-
for (const para of String(text).split('\n')) {
|
|
51
|
-
if (!para.trim()) {
|
|
52
|
-
out.push('');
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
let line = '';
|
|
56
|
-
for (const word of para.split(/\s+/)) {
|
|
57
|
-
if (!line) {
|
|
58
|
-
line = word;
|
|
59
|
-
} else if (line.length + 1 + word.length <= room) {
|
|
60
|
-
line += ' ' + word;
|
|
61
|
-
} else {
|
|
62
|
-
out.push(indent + line);
|
|
63
|
-
line = word;
|
|
64
|
-
}
|
|
65
|
-
// A single word longer than the room is hard-split rather than allowed to
|
|
66
|
-
// overflow — a long import specifier is exactly the case that shows up.
|
|
67
|
-
while (line.length > room) {
|
|
68
|
-
out.push(indent + line.slice(0, room));
|
|
69
|
-
line = line.slice(room);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
out.push(indent + line);
|
|
73
|
-
}
|
|
74
|
-
return out.join('\n');
|
|
75
|
-
}
|
|
48
|
+
// `wrapText` now lives in `./wrap.js`, shared with the command table's help screen.
|
|
76
49
|
|
|
77
50
|
// -------------------------------------------------------------- overview ---
|
|
78
51
|
|
|
@@ -148,29 +121,34 @@ export function tree(repo, { sub = '', depth = 2, limit = MAX } = {}) {
|
|
|
148
121
|
const walk = (node, depthLeft, indent) => {
|
|
149
122
|
for (const child of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
150
123
|
if (budget <= 0) return;
|
|
151
|
-
const room = Math.max(
|
|
152
|
-
lines.push(
|
|
124
|
+
const room = Math.max(4, termWidth() - indent.length - 2 - 4);
|
|
125
|
+
lines.push(labelled(dim(GLYPHS.dir + ' '), child.name, dim(` ${countUnder(child)}`), { pad: indent.length }));
|
|
153
126
|
budget--;
|
|
154
127
|
if (depthLeft > 1) walk(child, depthLeft - 1, indent + ' ');
|
|
155
128
|
}
|
|
156
129
|
for (const f of node.files) {
|
|
157
130
|
if (budget <= 0) return;
|
|
158
|
-
lines.push(
|
|
131
|
+
lines.push(fileLine(repo, f, indent.length));
|
|
159
132
|
budget--;
|
|
160
133
|
}
|
|
161
134
|
};
|
|
162
135
|
walk(root, Math.max(1, Number(depth) || 2), ' ');
|
|
163
136
|
|
|
164
|
-
const out = [
|
|
165
|
-
if (budget <= 0) out.push(dim('
|
|
137
|
+
const out = [dim(fit(` tree · ${prefix || '.'} ${scoped.length} files, depth ${Math.max(1, Number(depth) || 2)}`, Math.max(4, termWidth() - 2))), lines.join('\n')];
|
|
138
|
+
if (budget <= 0) out.push(dim(wrapText('… list stopped early — tree <folder> <depth> to go deeper', ' ')));
|
|
166
139
|
return out.join('\n');
|
|
167
140
|
}
|
|
168
141
|
|
|
169
|
-
function fileLine(repo, f) {
|
|
142
|
+
function fileLine(repo, f, indent = 2) {
|
|
170
143
|
const role = fileFacts(repo, f).role;
|
|
171
144
|
const badge = role === 'entry' ? ok(GLYPHS.entry) : role === 'hub' ? warn(GLYPHS.hub) : dim(GLYPHS.file);
|
|
172
|
-
|
|
173
|
-
|
|
145
|
+
// The name is the identifying part; the extension and line count are the first
|
|
146
|
+
// things to go when there is no room. `tree` on a phone-width terminal should
|
|
147
|
+
// still list the files, just less verbosely.
|
|
148
|
+
const tail = ' ' + dim(String(f.ext || '').padEnd(6)) + dim(String(f.loc || 0).padStart(5));
|
|
149
|
+
// The depth indent is part of the budget — it used to be prepended by the
|
|
150
|
+
// caller, which made every nested row as many cells too wide as its depth.
|
|
151
|
+
return labelled(badge + ' ', f.name, tail, { pad: indent });
|
|
174
152
|
}
|
|
175
153
|
|
|
176
154
|
function countUnder(node) {
|
|
@@ -187,21 +165,25 @@ function countUnder(node) {
|
|
|
187
165
|
// identifies the hit.
|
|
188
166
|
export function find(repo, { query = '', limit = 12 } = {}) {
|
|
189
167
|
if (!String(query).trim()) {
|
|
190
|
-
return dim('
|
|
168
|
+
return dim(wrapText('Try: find resolveImport · find ext:rs -test · find "exact phrase" · find /regex/', ' '));
|
|
191
169
|
}
|
|
192
170
|
const res = searchRepo(repo, query, { limit: Math.min(Number(limit) || 12, MAX) });
|
|
193
171
|
if (res.error) return bad(' ' + res.error);
|
|
194
|
-
if (!res.results.length) return dim(`
|
|
172
|
+
if (!res.results.length) return dim(wrapText(`No match for "${query}" in ${res.indexed} indexed files.`, ' '));
|
|
195
173
|
|
|
196
174
|
const w = termWidth();
|
|
197
|
-
|
|
175
|
+
// Path, line number and snippet share the terminal. The snippet is the least
|
|
176
|
+
// identifying part, so it goes first on a narrow terminal; `labelled` owns the
|
|
177
|
+
// indent, which is why the row cannot be `indent + name + count` glued
|
|
178
|
+
// together and then be a few cells too wide.
|
|
179
|
+
const lineRoom = 5;
|
|
198
180
|
const lines = res.results.map((r) => {
|
|
199
|
-
const snippetRoom = w -
|
|
181
|
+
const snippetRoom = w - 2 - lineRoom - 2;
|
|
200
182
|
const snip = snippetRoom > 10 ? dim(' ' + fit(r.snippet, snippetRoom, { tail: false })) : '';
|
|
201
|
-
return '
|
|
183
|
+
return labelled('', r.path, dim(String(r.line).padStart(lineRoom)) + snip, { pad: 2 });
|
|
202
184
|
});
|
|
203
185
|
const kind = res.advanced ? 'filtered query' : 'query';
|
|
204
|
-
const head = dim(` ${res.total} match${res.total === 1 ? '' : 'es'} (${kind}) across ${res.indexed} indexed files
|
|
186
|
+
const head = dim(fit(` ${res.total} match${res.total === 1 ? '' : 'es'} (${kind}) across ${res.indexed} indexed files`, Math.max(1, w - 2)));
|
|
205
187
|
return [head, ...lines, hintBar(['show <path> to read one', 'deps <path> to trace it'])].join('\n');
|
|
206
188
|
}
|
|
207
189
|
|
|
@@ -218,8 +200,15 @@ export async function show(repo, { target = '', from = 0, count = 0 } = {}) {
|
|
|
218
200
|
const file = found.file;
|
|
219
201
|
const text = await readRepoFile(repo, file.path);
|
|
220
202
|
const lines = text.split('\n');
|
|
221
|
-
|
|
222
|
-
|
|
203
|
+
// `null` here means the argument was present but not a usable line number
|
|
204
|
+
// (`show f 1.5`), which the command layer turns into a message rather than a
|
|
205
|
+
// silent fallback. A string means a caller passed a bad value, so say so
|
|
206
|
+
// instead of coercing it into a nonsensical range.
|
|
207
|
+
if (from === null || count === null) {
|
|
208
|
+
return bad(wrapText('Line numbers must be whole numbers.', ' ')) + dim(fit(' Try: `show ' + file.name + ' 40 20`', Math.max(1, termWidth() - 2)));
|
|
209
|
+
}
|
|
210
|
+
const start = Math.max(1, from || 1);
|
|
211
|
+
const room = count > 0 ? Math.min(count, 400) : Math.min(lines.length, 40);
|
|
223
212
|
const end = Math.min(lines.length, start + room - 1);
|
|
224
213
|
|
|
225
214
|
const gutter = String(end).length;
|
|
@@ -240,11 +229,21 @@ export async function show(repo, { target = '', from = 0, count = 0 } = {}) {
|
|
|
240
229
|
|
|
241
230
|
// Render a resolver miss the same way everywhere: the reason, then the
|
|
242
231
|
// candidates. A "did you mean" is only useful if it is actionable.
|
|
232
|
+
//
|
|
233
|
+
// The reason is word-wrapped, because a miss on a long path or an unfamiliar
|
|
234
|
+
// filename produces a long sentence, and the commands that surface this
|
|
235
|
+
// (symbols, blast, graph, blame) do not each remember to wrap it.
|
|
243
236
|
export function targetError(found) {
|
|
244
|
-
|
|
237
|
+
// The room is the terminal minus the 2-space lead. The `Math.max(8, …)` that
|
|
238
|
+
// used to be here was itself the bug on an 8-column terminal: a floor above the
|
|
239
|
+
// available width guarantees an overflow, which is the one thing this function
|
|
240
|
+
// exists to prevent. `wrapText` already clamps to a single cell.
|
|
241
|
+
const w = termWidth();
|
|
242
|
+
const room = Math.max(1, w - 6);
|
|
243
|
+
const lines = [bad(' ' + wrapText(found.error, ' ', room))];
|
|
245
244
|
if (found.candidates) {
|
|
246
|
-
for (const c of found.candidates) lines.push(' ' + cyan(c));
|
|
247
|
-
lines.push(dim(' show <one of these>'));
|
|
245
|
+
for (const c of found.candidates) lines.push(' ' + cyan(fit(c, Math.max(1, w - 4))));
|
|
246
|
+
lines.push(dim(fit(' show <one of these>', Math.max(1, w - 2))));
|
|
248
247
|
}
|
|
249
248
|
return lines.join('\n');
|
|
250
249
|
}
|
|
@@ -271,11 +270,11 @@ export function deps(repo, { target = '', limit = 15 } = {}) {
|
|
|
271
270
|
{ label: 'dependents', value: `${f.fanIn} file${f.fanIn === 1 ? '' : 's'} import this` },
|
|
272
271
|
{ label: 'imports', value: `${f.fanOut} file${f.fanOut === 1 ? '' : 's'} pulled in` },
|
|
273
272
|
])];
|
|
274
|
-
if (f.inCycle) head.push(bad(' ' + GLYPHS.warn + ' part of a circular import — refactoring here ripples'));
|
|
273
|
+
if (f.inCycle) head.push(bad(fit(' ' + GLYPHS.warn + ' part of a circular import — refactoring here ripples', Math.max(1, w - 2))));
|
|
275
274
|
|
|
276
275
|
const list = (title, items) => {
|
|
277
|
-
if (!items.length) return dim(` ${title}: none
|
|
278
|
-
return [dim(` ${title}
|
|
276
|
+
if (!items.length) return dim(fit(` ${title}: none`, Math.max(1, w - 2)));
|
|
277
|
+
return [dim(fit(` ${title}:`, Math.max(1, w - 2))), ...items.map((x) => ' ' + cyan(fit(x, Math.max(1, w - 4))))].join('\n');
|
|
279
278
|
};
|
|
280
279
|
return [...head, list('imports', imports), list('imported by', importers)].join('\n');
|
|
281
280
|
}
|
|
@@ -320,13 +319,14 @@ function renderProse(md) {
|
|
|
320
319
|
// prompt and an agent asking the MCP server are being told the same thing about
|
|
321
320
|
// the same repository.
|
|
322
321
|
export function tour(repo) {
|
|
323
|
-
if (!repo.tour.length) return dim('
|
|
322
|
+
if (!repo.tour.length) return dim(wrapText('No tour stops found — is this a code repository?', ' '));
|
|
324
323
|
const lines = repo.tour.map((stop, i) => {
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
// The number is the marker and the path is the value, so the tour stops use
|
|
325
|
+
// the same helper as every other ranked list and cannot outgrow the terminal.
|
|
326
|
+
const head = labelled(dim(String(i + 1).padStart(2) + '. '), stop.path, '', { pad: 2 });
|
|
327
327
|
return [head, dim(wrapText(stop.why, ' '))].join('\n');
|
|
328
328
|
});
|
|
329
|
-
return [
|
|
329
|
+
return [dim(fit(' reading order', Math.max(4, termWidth() - 2))), ...lines, hintBar(['show <path> to read one'])].join('\n');
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
// ---------------------------------------------------------------- health ---
|
|
@@ -335,6 +335,7 @@ export function tour(repo) {
|
|
|
335
335
|
// breakdown follows because a letter with no reasons is not actionable.
|
|
336
336
|
export function health(repo) {
|
|
337
337
|
const h = repo.health;
|
|
338
|
+
const w = termWidth();
|
|
338
339
|
const rows = [
|
|
339
340
|
{ label: 'grade', value: gradeColor(h.grade) + dim(` score ${h.score}/100`) },
|
|
340
341
|
{ label: 'debt', value: `${h.totals.crit} critical · ${h.totals.high} high findings` },
|
|
@@ -350,24 +351,23 @@ export function health(repo) {
|
|
|
350
351
|
.slice(0, 8);
|
|
351
352
|
const out = [panel('health', rows)];
|
|
352
353
|
if (risky.length) {
|
|
353
|
-
out.push(dim(' riskiest files:'));
|
|
354
|
+
out.push(dim(fit(' riskiest files:', Math.max(4, termWidth() - 2))));
|
|
354
355
|
for (const f of risky) {
|
|
355
356
|
// Risk is an integer 0–100; printing "58.00" would imply a precision the
|
|
356
|
-
// number does not have.
|
|
357
|
+
// number does not have. The suffix is dropped on a narrow terminal rather
|
|
358
|
+
// than allowed to push the path off the edge.
|
|
357
359
|
const risk = Number(f.risk);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
+
const tail = dim(` risk ${risk}${f.blast !== undefined ? ` blast ${f.blast}` : ''}`);
|
|
361
|
+
out.push(labelled(warn(GLYPHS.warn + ' '), cyan(f.path), tail, { pad: 4 }));
|
|
360
362
|
}
|
|
361
363
|
}
|
|
362
364
|
if (h.breakdown?.length) {
|
|
363
365
|
// The breakdown is the score itemized — each entry is what a factor cost.
|
|
364
366
|
// Showing the label without the points would explain the grade without
|
|
365
367
|
// showing the arithmetic, which is the part that makes it actionable.
|
|
366
|
-
out.push(dim(' what moved the score:'));
|
|
368
|
+
out.push(dim(fit(' what moved the score:', Math.max(4, termWidth() - 2))));
|
|
367
369
|
for (const b of h.breakdown) {
|
|
368
|
-
|
|
369
|
-
const room = Math.max(12, termWidth() - 10);
|
|
370
|
-
out.push(' ' + dim('· ') + fit(b.label, room) + ' ' + pts);
|
|
370
|
+
out.push(labelled(dim('· '), b.label, bad(String(b.points)), { pad: 4 }));
|
|
371
371
|
}
|
|
372
372
|
}
|
|
373
373
|
return out.join('\n');
|
|
@@ -379,13 +379,15 @@ export function health(repo) {
|
|
|
379
379
|
// "which file is load-bearing" only has an answer as an ordering.
|
|
380
380
|
export function hubs(repo, { limit = 12 } = {}) {
|
|
381
381
|
const list = repo.facts.hubs.slice(0, Math.min(Number(limit) || 12, MAX));
|
|
382
|
-
if (!list.length) return dim('
|
|
382
|
+
if (!list.length) return dim(wrapText('No hubs — nothing here is imported by two or more files.', ' '));
|
|
383
383
|
const w = termWidth();
|
|
384
384
|
const top = list[0].fanIn || 1;
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
// The bar is decoration and is the tail, so it is the first thing dropped on a
|
|
386
|
+
// narrow terminal. The in/out counts are the reason the file is on the list.
|
|
387
|
+
const barRoom = Math.max(0, Math.min(24, w - 30));
|
|
388
|
+
return [dim(fit(' most depended-on files', Math.max(4, w - 2))), ...list.map((h) => {
|
|
387
389
|
const bar = barRoom > 4 ? dim(' ' + '█'.repeat(Math.max(1, Math.round((h.fanIn / top) * barRoom)))) : '';
|
|
388
|
-
return '
|
|
390
|
+
return labelled('', h.path, dim(` ${h.fanIn} in / ${h.fanOut} out`) + bar, { pad: 2 });
|
|
389
391
|
})].join('\n');
|
|
390
392
|
}
|
|
391
393
|
|
|
@@ -395,13 +397,15 @@ export function hubs(repo, { limit = 12 } = {}) {
|
|
|
395
397
|
// layer 0 is what runs, and each step down is something it can reach.
|
|
396
398
|
export function layers(repo, { limit = 12 } = {}) {
|
|
397
399
|
const ls = repo.layers.layers;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
+
const w = termWidth();
|
|
401
|
+
if (!ls.length) return dim(wrapText('No layers — no import chain starts anywhere we recognize.'));
|
|
402
|
+
const out = [dim(fit(' import depth', Math.max(4, termWidth() - 2)))];
|
|
400
403
|
ls.slice(0, Math.min(Number(limit) || 12, 40)).forEach((files, i) => {
|
|
401
|
-
|
|
404
|
+
// The "+N more" note is the tail, so a terminal that cannot hold the path
|
|
405
|
+
// *and* the note drops the note rather than overflowing. Same rule as every
|
|
406
|
+
// other labelled row, which is why it goes through the same helper.
|
|
402
407
|
const more = files.length > 3 ? dim(` +${files.length - 3} more`) : '';
|
|
403
|
-
|
|
404
|
-
out.push(dim(` layer ${i} (${files.length}) `) + cyan(fit(shown, room)) + more);
|
|
408
|
+
out.push(labelled(dim(` layer ${i} (${files.length}) `), cyan(files.slice(0, 3).join(', ')), more));
|
|
405
409
|
});
|
|
406
410
|
if (repo.layers.unreachable.length) {
|
|
407
411
|
out.push(dim(wrapText(`${repo.layers.unreachable.length} files are not reachable from any entry point`)));
|
|
@@ -414,14 +418,14 @@ export function layers(repo, { limit = 12 } = {}) {
|
|
|
414
418
|
// The architecture, in prose. These are the observations `detectPatterns`
|
|
415
419
|
// makes, in the order a senior dev would mention them.
|
|
416
420
|
export function patterns(repo) {
|
|
417
|
-
if (!repo.patterns.length) return dim('
|
|
418
|
-
const out = [
|
|
421
|
+
if (!repo.patterns.length) return dim(wrapText('No patterns stood out.', ' '));
|
|
422
|
+
const out = [dim(fit(' what this codebase looks like', Math.max(4, termWidth() - 2)))];
|
|
419
423
|
for (const p of repo.patterns) {
|
|
420
424
|
const mark = p.tone === 'good' ? ok(GLYPHS.good) : p.tone === 'warn' ? bad(GLYPHS.warn) : warn('·');
|
|
421
|
-
out.push(' ' + mark + ' ' +
|
|
425
|
+
out.push(' ' + mark + ' ' + dim(fit(p.title, Math.max(4, termWidth() - 4))));
|
|
422
426
|
out.push(dim(wrapText(p.detail, ' ')));
|
|
423
427
|
if (p.paths?.length) {
|
|
424
|
-
out.push(dim(' ' + fit(p.paths.slice(0, 4).join(' '), Math.max(
|
|
428
|
+
out.push(dim(' ' + fit(p.paths.slice(0, 4).join(' '), Math.max(4, termWidth() - 4))));
|
|
425
429
|
}
|
|
426
430
|
}
|
|
427
431
|
return out.join('\n');
|
|
@@ -439,28 +443,43 @@ export function stats(repo, { limit = 10 } = {}) {
|
|
|
439
443
|
const cx = [...repo.scan.files].filter((f) => f.complexity > 0).sort((a, b) => b.complexity - a.complexity).slice(0, 5);
|
|
440
444
|
|
|
441
445
|
const totalLoc = repo.languages.reduce((s2, l) => s2 + l.loc, 0);
|
|
446
|
+
// One label column for every ranked list, sized from the terminal rather than
|
|
447
|
+
// hardcoded at 34. The old fixed padding meant a 20-column terminal got 45
|
|
448
|
+
// columns of output — the number was right and the layout was still wrong.
|
|
449
|
+
const w = termWidth();
|
|
450
|
+
const labels = [
|
|
451
|
+
...repo.languages.slice(0, 8).map((l) => l.label),
|
|
452
|
+
...folders.map((d) => d.path),
|
|
453
|
+
...top.map((f) => f.path),
|
|
454
|
+
...cx.map((f) => f.path),
|
|
455
|
+
];
|
|
456
|
+
const labelRoom = Math.max(4, Math.min(34, Math.max(0, ...labels.map((s) => s.length)) + 1, Math.floor(w * 0.45)));
|
|
457
|
+
// The number keeps at least 4 cells and the label gets the rest, so the row is
|
|
458
|
+
// `indent + label + number` and never exceeds the terminal at any width.
|
|
459
|
+
const numRoom = Math.max(4, Math.min(7, w - labelRoom - 5));
|
|
460
|
+
const statRow = (s, n) => labelled('', s, dim(String(n).padStart(numRoom)), { pad: 4 });
|
|
461
|
+
|
|
442
462
|
const out = [panel('stats', [
|
|
443
463
|
{ label: 'files', value: `${s.filesParsed} parsed of ${s.filesTotal} seen` },
|
|
444
|
-
{ label: 'lines', value: `${totalLoc} code` + (s.truncated ? warn(
|
|
464
|
+
{ label: 'lines', value: `${totalLoc} code` + (s.truncated ? warn(' (partial scan)') : '') },
|
|
445
465
|
{ label: 'imports', value: `${s.edgeCount} resolved` + (s.imports?.total ? dim(` ${s.imports.confidence}% placed`) : '') },
|
|
446
466
|
{ label: 'tests', value: `${repo.facts.testCoverage.ratio}% of non-test files are covered by a test import` },
|
|
447
467
|
])];
|
|
448
468
|
|
|
449
|
-
out.push(
|
|
450
|
-
for (const l of repo.languages.slice(0, 8))
|
|
451
|
-
|
|
452
|
-
}
|
|
469
|
+
out.push(dim(fit(' languages', Math.max(4, w - 2))));
|
|
470
|
+
for (const l of repo.languages.slice(0, 8)) out.push(statRow(l.label, l.loc));
|
|
471
|
+
|
|
453
472
|
if (folders.length) {
|
|
454
|
-
out.push(
|
|
455
|
-
for (const d of folders) out.push(
|
|
473
|
+
out.push(dim(fit(' biggest folders (loc)', Math.max(4, termWidth() - 2))));
|
|
474
|
+
for (const d of folders) out.push(statRow(d.path, d.loc));
|
|
456
475
|
}
|
|
457
476
|
if (top.length) {
|
|
458
|
-
out.push(
|
|
459
|
-
for (const f of top) out.push(
|
|
477
|
+
out.push(dim(fit(' biggest files (loc)', Math.max(4, termWidth() - 2))));
|
|
478
|
+
for (const f of top) out.push(statRow(f.path, f.loc));
|
|
460
479
|
}
|
|
461
480
|
if (cx.length) {
|
|
462
|
-
out.push(
|
|
463
|
-
for (const f of cx) out.push(
|
|
481
|
+
out.push(dim(fit(' most complex', Math.max(4, w - 2))));
|
|
482
|
+
for (const f of cx) out.push(statRow(f.path, f.complexity));
|
|
464
483
|
}
|
|
465
484
|
return out.join('\n');
|
|
466
485
|
}
|
|
@@ -469,7 +488,8 @@ export function stats(repo, { limit = 10 } = {}) {
|
|
|
469
488
|
|
|
470
489
|
export function security(repo, { limit = 12 } = {}) {
|
|
471
490
|
const sec = repo.security;
|
|
472
|
-
|
|
491
|
+
const w = termWidth();
|
|
492
|
+
if (!sec.total) return ok(wrapText('No findings from the built-in rules.', ' '));
|
|
473
493
|
const order = ['critical', 'high', 'medium', 'low', 'info'];
|
|
474
494
|
const counts = order.filter((k) => sec.counts[k]).map((k) => `${sec.counts[k]} ${k}`).join(' · ');
|
|
475
495
|
const out = [panel('security', [
|
|
@@ -477,8 +497,8 @@ export function security(repo, { limit = 12 } = {}) {
|
|
|
477
497
|
{ label: 'findings', value: counts },
|
|
478
498
|
])];
|
|
479
499
|
for (const f of sec.files.slice(0, Math.min(Number(limit) || 12, MAX))) {
|
|
480
|
-
|
|
481
|
-
|
|
500
|
+
const mark = (f.worst === 'critical' || f.worst === 'high' ? bad : warn)(GLYPHS.warn + ' ');
|
|
501
|
+
out.push(labelled(mark, cyan(f.path), dim(` ${f.count} · worst ${f.worst}`), { pad: 4 }));
|
|
482
502
|
}
|
|
483
503
|
out.push(dim(wrapText('Pattern-based heuristics only — not a substitute for a real audit.')));
|
|
484
504
|
return out.join('\n');
|
|
@@ -486,14 +506,23 @@ export function security(repo, { limit = 12 } = {}) {
|
|
|
486
506
|
|
|
487
507
|
// ----------------------------------------------------------------- stack ---
|
|
488
508
|
|
|
489
|
-
// The dependency picture the site shows, from the same `analyzeStack`.
|
|
509
|
+
// The dependency picture the site shows, from the same `analyzeStack`. Columns
|
|
510
|
+
// are computed from the widest *actual* value rather than fixed padding, so a
|
|
511
|
+
// long scoped package name shrinks the columns instead of pushing the row off
|
|
512
|
+
// the right edge.
|
|
490
513
|
export function stack(repo, { limit = 20 } = {}) {
|
|
491
514
|
const st = repo.stack;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
515
|
+
const w = termWidth();
|
|
516
|
+
if (!st.items.length) return dim(wrapText('No recognized dependencies (no package manifest found?).', ' '));
|
|
517
|
+
const items = st.items.slice(0, Math.min(Number(limit) || 20, MAX));
|
|
518
|
+
|
|
519
|
+
const out = [dim(fit(' stack ' + (st.pm.join(', ') || 'no package manager'), Math.max(1, w - 2)))];
|
|
520
|
+
for (const it of items) {
|
|
521
|
+
// Same shape as every other row: the name is the value, the category and
|
|
522
|
+
// version are the tail, and the tail is what a narrow terminal drops. The
|
|
523
|
+
// old version computed its own column budget and was three cells wide.
|
|
524
|
+
const tail = dim(it.category) + (it.version ? ' ' + it.version : '') + (it.dev ? ' dev' : '');
|
|
525
|
+
out.push(labelled('', it.name, tail, { pad: 4 }));
|
|
497
526
|
}
|
|
498
527
|
return out.join('\n');
|
|
499
528
|
}
|
|
@@ -502,11 +531,12 @@ export function stack(repo, { limit = 20 } = {}) {
|
|
|
502
531
|
|
|
503
532
|
export function entry(repo) {
|
|
504
533
|
if (!repo.facts.entries.length) {
|
|
505
|
-
return dim('
|
|
534
|
+
return dim(wrapText('No entry points recognized. tour falls back to the most depended-on files.'));
|
|
506
535
|
}
|
|
507
|
-
|
|
536
|
+
const w = termWidth();
|
|
537
|
+
return [dim(fit(' entry points', Math.max(4, termWidth() - 2))), ...repo.facts.entries.map((p) => {
|
|
508
538
|
const out = repo.facts.fanOut[p] || 0;
|
|
509
|
-
return
|
|
539
|
+
return labelled(ok(GLYPHS.entry + ' '), cyan(p), dim(` reaches ${out} files`), { pad: 4 });
|
|
510
540
|
})].join('\n');
|
|
511
541
|
}
|
|
512
542
|
|
|
@@ -517,19 +547,31 @@ export function entry(repo) {
|
|
|
517
547
|
// audit actually acts on.
|
|
518
548
|
export function externals(repo) {
|
|
519
549
|
const ext = repo.scan.externals || [];
|
|
520
|
-
if (!ext.length) return dim('
|
|
550
|
+
if (!ext.length) return dim(wrapText('No external imports found.'));
|
|
551
|
+
const w = termWidth();
|
|
521
552
|
const sorted = [...ext].sort((a, b) => (b.usedBy?.length || 0) - (a.usedBy?.length || 0));
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
553
|
+
const shown = sorted.slice(0, 30);
|
|
554
|
+
// Two columns when they fit, one per line when they do not. A scoped package
|
|
555
|
+
// name is long enough on its own to push a fixed-width column off a narrow
|
|
556
|
+
// terminal, so the column width follows the data.
|
|
557
|
+
const nameRoom = Math.max(8, Math.min(28, Math.max(...shown.map((x) => String(x.name).length))));
|
|
558
|
+
const out = [dim(fit(' external packages', Math.max(4, w - 2)))];
|
|
559
|
+
if (w < nameRoom + 16) {
|
|
560
|
+
for (const x of shown) {
|
|
561
|
+
out.push(labelled('', `${x.name} · ${x.usedBy?.length || 0} files`, '', { pad: 4 }));
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
for (const x of shown) {
|
|
565
|
+
out.push(labelled('', x.name, dim(`${x.usedBy?.length || 0} files`), { pad: 4 }));
|
|
566
|
+
}
|
|
525
567
|
}
|
|
526
568
|
const drift = repo.facts.depsDrift;
|
|
527
569
|
if (drift?.undeclaredImported?.length) {
|
|
528
|
-
out.push(
|
|
570
|
+
out.push(dim(fit(' imported but not declared', Math.max(4, termWidth() - 2))));
|
|
529
571
|
out.push(wrapText(drift.undeclaredImported.join(', '), ' ').split('\n').map(warn).join('\n'));
|
|
530
572
|
}
|
|
531
573
|
if (drift?.unusedDeclared?.length) {
|
|
532
|
-
out.push(
|
|
574
|
+
out.push(dim(fit(' declared but never imported', Math.max(4, termWidth() - 2))));
|
|
533
575
|
out.push(dim(wrapText(drift.unusedDeclared.join(', '), ' ')));
|
|
534
576
|
}
|
|
535
577
|
return out.join('\n');
|
|
@@ -540,10 +582,69 @@ export function externals(repo) {
|
|
|
540
582
|
// Which binary is answering. When the terminal and the website disagree, this
|
|
541
583
|
// is the first thing to ask.
|
|
542
584
|
export function about(repo, version = '') {
|
|
543
|
-
|
|
585
|
+
const rows = [
|
|
544
586
|
{ label: 'version', value: version },
|
|
545
587
|
{ label: 'repo', value: repo.root },
|
|
546
588
|
{ label: 'indexed', value: `${repo.searchIndex?.totalDocs ?? 0} files for find` },
|
|
547
|
-
{ label: 'scanned', value: repo.scan.scannedAt },
|
|
548
|
-
]
|
|
589
|
+
{ label: 'scanned', value: `${repo.scan.scannedAt}` },
|
|
590
|
+
];
|
|
591
|
+
if (repo.gitUrl) rows.push({ label: 'from', value: repo.gitUrl });
|
|
592
|
+
if (repo.cloneDir) rows.push({ label: 'clone', value: 'a temp clone — removed when you leave' });
|
|
593
|
+
return panel('onboarder', rows);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// `github` — the terminal's answer to the site's "From the remote" section:
|
|
597
|
+
// what GitHub says about this repository, as opposed to what reading the code
|
|
598
|
+
// says about it. The distinction is the point. Stars and issues are about the
|
|
599
|
+
// project's standing; `health` and `risks` are about the code in front of you,
|
|
600
|
+
// and neither can tell you the other.
|
|
601
|
+
//
|
|
602
|
+
// The result is passed in rather than fetched here, because every function in
|
|
603
|
+
// this file is pure and testable with no network. `ctx.github()` does the asking.
|
|
604
|
+
export function github(repo, result) {
|
|
605
|
+
if (!result) {
|
|
606
|
+
return dim(wrapText('Not a GitHub repository, so there is nothing to ask GitHub about.', ' '));
|
|
607
|
+
}
|
|
608
|
+
if (!result.ok) {
|
|
609
|
+
return [bad(fit(' ' + result.reason, Math.max(1, termWidth() - 2))), dim(wrapText(githubHintText(repo), ' '))].join('\n');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const f = result.facts;
|
|
613
|
+
const rows = [
|
|
614
|
+
row('repo', result.repoPath),
|
|
615
|
+
row('stars', String(f.stars)),
|
|
616
|
+
row('forks', String(f.forks)),
|
|
617
|
+
row('watching', String(f.watching)),
|
|
618
|
+
row('open issues', String(f.issues)),
|
|
619
|
+
];
|
|
620
|
+
if (f.license) rows.push(row('license', f.license));
|
|
621
|
+
if (f.branch) rows.push(row('branch', f.branch));
|
|
622
|
+
if (f.created) rows.push(row('created', shortDate(f.created)));
|
|
623
|
+
if (f.pushed) rows.push(row('last push', shortDate(f.pushed)));
|
|
624
|
+
if (f.archived) rows.push(row('note', 'this repository is archived'));
|
|
625
|
+
|
|
626
|
+
const out = [panel(result.repoPath, rows)];
|
|
627
|
+
if (f.description) out.push(dim(wrapText(f.description)));
|
|
628
|
+
if (f.topics.length) out.push(dim(fit(' topics ' + f.topics.join(' '), Math.max(1, termWidth() - 2))));
|
|
629
|
+
if (f.homepage) out.push(dim(fit(' site ' + f.homepage, Math.max(1, termWidth() - 2))));
|
|
630
|
+
// Double quotes rather than an escaped apostrophe: the previous spelling of
|
|
631
|
+
// this line was a `\\'` inside a single-quoted string, which is a syntax error
|
|
632
|
+
// waiting for the next person to touch the file.
|
|
633
|
+
out.push(dim(wrapText("These are GitHub's numbers, not this repo's. `health` and `risks` are about the code.", ' ')));
|
|
634
|
+
return out.join('\n');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Returned as plain text and wrapped by the caller: a hint line is exactly the
|
|
638
|
+
// kind of prose that overflows, because it is written once and never measured.
|
|
639
|
+
function githubHintText(repo) {
|
|
640
|
+
if (repo.gitUrl) return 'It is a clone, so the URL is known — the ask itself failed.';
|
|
641
|
+
return 'No origin remote here, so there is no URL to ask about.';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// `2024-01-05T…` → `Jan 2024`. A day is noise next to a five-year-old project, and
|
|
645
|
+
// a full timestamp is four columns wider than the label it sits beside.
|
|
646
|
+
function shortDate(iso) {
|
|
647
|
+
const d = new Date(iso);
|
|
648
|
+
if (Number.isNaN(d.getTime())) return String(iso);
|
|
649
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
|
|
549
650
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Word-wrap plain text to the terminal, preserving the indent on continuation
|
|
2
|
+
// lines.
|
|
3
|
+
//
|
|
4
|
+
// This lives in its own module because two callers need it: the views, which
|
|
5
|
+
// wrap the engine's long prose, and the command table, which fits its help
|
|
6
|
+
// screen. Both must agree on the same edge behavior, or the same string comes
|
|
7
|
+
// out two different widths depending on who printed it.
|
|
8
|
+
//
|
|
9
|
+
// Applied *before* painting, so the wrap never has to understand ANSI: the
|
|
10
|
+
// styled string is built from already-wrapped plain text.
|
|
11
|
+
//
|
|
12
|
+
// `room` is clamped to at least one cell. Without that floor, a terminal
|
|
13
|
+
// narrower than the indent drives `room` to zero or below, the hard-split loop
|
|
14
|
+
// slices a word to the empty string and re-reads the same word forever, and the
|
|
15
|
+
// session hangs instead of printing. A tool that reads code should degrade on a
|
|
16
|
+
// tiny terminal, never wedge on one.
|
|
17
|
+
|
|
18
|
+
import { termWidth } from '../ui.js';
|
|
19
|
+
|
|
20
|
+
export function wrapText(text, indent = ' ', room = termWidth() - indent.length) {
|
|
21
|
+
const cell = Math.max(1, Math.floor(room));
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const para of String(text).split('\n')) {
|
|
24
|
+
if (!para.trim()) {
|
|
25
|
+
out.push('');
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let line = '';
|
|
29
|
+
for (const word of para.split(/\s+/)) {
|
|
30
|
+
if (!line) {
|
|
31
|
+
line = word;
|
|
32
|
+
} else if (line.length + 1 + word.length <= cell) {
|
|
33
|
+
line += ' ' + word;
|
|
34
|
+
} else {
|
|
35
|
+
out.push(indent + line);
|
|
36
|
+
line = word;
|
|
37
|
+
}
|
|
38
|
+
// A single word longer than the room is hard-split rather than allowed to
|
|
39
|
+
// overflow — a long import specifier is exactly the case that shows up.
|
|
40
|
+
// `cell >= 1` guarantees each pass consumes a character and terminates.
|
|
41
|
+
while (line.length > cell) {
|
|
42
|
+
out.push(indent + line.slice(0, cell));
|
|
43
|
+
line = line.slice(cell);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
out.push(indent + line);
|
|
47
|
+
}
|
|
48
|
+
return out.join('\n');
|
|
49
|
+
}
|