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.
@@ -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
- const room = Math.max(8, termWidth() - 2);
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
- // Word-wrap plain text to the terminal, preserving the indent on continuation
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(8, termWidth() - indent.length - 12);
152
- lines.push(indent + dim(GLYPHS.dir + ' ') + cyan(fit(child.name, room)) + dim(` ${countUnder(child)}`));
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(indent + fileLine(repo, f));
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 = [bold(' tree · ' + (prefix || '.')) + dim(` ${scoped.length} files, depth ${Math.max(1, Number(depth) || 2)}`), lines.join('\n')];
165
- if (budget <= 0) out.push(dim(' … list stopped early — tree <folder> <depth> to go deeper'));
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
- const room = Math.max(10, termWidth() - 22);
173
- return badge + ' ' + fit(f.name, room) + ' ' + dim(String(f.ext || '').padEnd(6)) + dim(String(f.loc || 0).padStart(5));
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(' Try: find resolveImport · find ext:rs -test · find "exact phrase" · find /regex/');
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(` No match for "${query}" in ${res.indexed} indexed files.`);
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
- const pathRoom = Math.max(16, Math.min(46, Math.floor(w * 0.42)));
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 - pathRoom - 18;
181
+ const snippetRoom = w - 2 - lineRoom - 2;
200
182
  const snip = snippetRoom > 10 ? dim(' ' + fit(r.snippet, snippetRoom, { tail: false })) : '';
201
- return ' ' + fit(r.path, pathRoom) + dim(String(r.line).padStart(5)) + ' ' + snip;
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
- const start = Math.max(1, Number(from) || 1);
222
- const room = count > 0 ? Math.min(Number(count), 400) : Math.min(lines.length, 40);
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
- const lines = [bad(' ' + found.error)];
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}:`), ...items.map((x) => ' ' + cyan(fit(x, w - 6)))].join('\n');
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(' No tour stops found — is this a code repository?');
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
- const n = dim(String(i + 1).padStart(2) + '.');
326
- const head = n + ' ' + cyan(fit(stop.path, Math.max(20, Math.floor(termWidth() * 0.45))));
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 [bold(' reading order'), ...lines, hintBar(['show <path> to read one'])].join('\n');
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
- out.push(' ' + warn(GLYPHS.warn + ' ') + cyan(fit(f.path, Math.max(20, termWidth() - 40)))
359
- + dim(` risk ${risk}${f.blast !== undefined ? ` blast ${f.blast}` : ''}`));
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
- const pts = bad(String(b.points));
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(' No hubs — nothing here is imported by two or more files.');
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
- const barRoom = Math.max(0, Math.min(24, w - 46));
386
- return [bold(' most depended-on files'), ...list.map((h) => {
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 ' ' + cyan(fit(h.path, Math.max(20, w - 12 - barRoom))) + dim(` ${h.fanIn} in / ${h.fanOut} out`) + bar;
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
- if (!ls.length) return dim(' No layers — no import chain starts anywhere we recognize.');
399
- const out = [bold(' import depth')];
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
- const shown = files.slice(0, 3).join(', ');
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
- const room = Math.max(12, termWidth() - String(i).length - 14 - (more ? String(more.length + 2) : 0));
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(' No patterns stood out.');
418
- const out = [bold(' what this codebase looks like')];
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 + ' ' + bold(p.title));
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(10, termWidth() - 4))));
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(` (partial scan)`) : '') },
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(bold(' languages'));
450
- for (const l of repo.languages.slice(0, 8)) {
451
- out.push(' ' + cyan(l.label.padEnd(14)) + dim(String(l.loc).padStart(7)));
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(bold(' biggest folders (loc)'));
455
- for (const d of folders) out.push(' ' + cyan(fit(d.path, 34).padEnd(34)) + dim(String(d.loc).padStart(7)));
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(bold(' biggest files (loc)'));
459
- for (const f of top) out.push(' ' + cyan(fit(f.path, 34).padEnd(34)) + dim(String(f.loc).padStart(7)));
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(bold(' most complex'));
463
- for (const f of cx) out.push(' ' + cyan(fit(f.path, 34).padEnd(34)) + dim(String(f.complexity).padStart(7)));
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
- if (!sec.total) return ok(' No findings from the built-in rules.');
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
- out.push(' ' + (f.worst === 'critical' || f.worst === 'high' ? bad : warn)(GLYPHS.warn + ' ')
481
- + cyan(fit(f.path, Math.max(20, termWidth() - 34))) + dim(` ${f.count} · worst ${f.worst}`));
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
- if (!st.items.length) return dim(wrapText('No recognized dependencies (no package manifest found?).'));
493
- const out = [bold(' stack') + dim(` ${st.pm.join(', ') || 'no package manager'}`)];
494
- for (const it of st.items.slice(0, Math.min(Number(limit) || 20, MAX))) {
495
- const v = it.version ? dim(' ' + it.version) : '';
496
- out.push(' ' + cyan(it.name.padEnd(28)) + dim(String(it.category).padEnd(12)) + v + (it.dev ? dim(' dev') : ''));
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(' No entry points recognized. tour falls back to the most depended-on files.');
534
+ return dim(wrapText('No entry points recognized. tour falls back to the most depended-on files.'));
506
535
  }
507
- return [bold(' entry points'), ...repo.facts.entries.map((p) => {
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 ' ' + ok(GLYPHS.entry + ' ') + cyan(fit(p, Math.max(20, termWidth() - 24))) + dim(` reaches ${out} files`);
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(' No external imports found.');
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 out = [bold(' external packages')];
523
- for (const x of sorted.slice(0, 30)) {
524
- out.push(' ' + cyan(String(x.name).padEnd(28)) + dim(`${x.usedBy?.length || 0} files`));
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(bold(' imported but not declared'));
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(bold(' declared but never imported'));
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
- return panel('onboarder', [
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
+ }