codebase-onboarder 0.4.1 → 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.
@@ -0,0 +1,369 @@
1
+ // The command table.
2
+ //
3
+ // One list, used three ways: to dispatch what someone typed, to build the `help`
4
+ // screen, and to assert in the tests that every command is reachable, has a
5
+ // summary, and is wired to a real view. A command that exists in one of those
6
+ // places but not the others is the kind of thing nobody notices until a user
7
+ // types it, so the table is the only place a command is defined at all.
8
+ //
9
+ // `run(ctx, args)` returns a string to print, or a marker the app layer acts on
10
+ // (`EXIT`, `CLEAR`). `args` is an array of already-parsed words, so quoting is
11
+ // handled once, in the tokenizer, and every command sees the same shape.
12
+
13
+ import * as V from './views.js';
14
+ import * as A from './advanced.js';
15
+ import { lineNumber } from './session.js';
16
+ import { bold, cyan, dim, ok, fit, termWidth } from '../ui.js';
17
+ import { wrapText } from './wrap.js';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+
21
+ const run = promisify(execFile);
22
+
23
+ export const EXIT = Symbol('exit');
24
+ export const CLEAR = Symbol('clear');
25
+
26
+ export const COMMANDS = [
27
+ {
28
+ name: 'help', aliases: ['?'], group: 'basics',
29
+ usage: 'help [command]', summary: 'This list, or everything about one command.',
30
+ // The topic is forwarded. `help find` used to ignore its argument and print
31
+ // the whole list, which reads as the command not working.
32
+ run: (ctx, args) => ctx.help(args.join(' ')),
33
+ },
34
+ {
35
+ name: 'map', aliases: ['overview', 'home'], group: 'basics',
36
+ usage: 'map', summary: 'What this repo is, and where to start.',
37
+ run: (ctx) => V.overview(ctx.repo),
38
+ },
39
+ {
40
+ name: 'tour', aliases: ['start', 'onboarding'], group: 'basics',
41
+ usage: 'tour', summary: 'The reading order a new teammate should follow.',
42
+ run: (ctx) => V.tour(ctx.repo),
43
+ },
44
+ {
45
+ name: 'explain', aliases: ['why', 'what'], group: 'basics',
46
+ usage: 'explain [file|folder]', summary: 'This repo, or one file/folder, in prose. No AI key needed.',
47
+ run: (ctx, args) => V.explain(ctx.repo, { target: args.join(' ') }),
48
+ },
49
+ {
50
+ name: 'tree', aliases: ['ls', 'files'], group: 'navigate',
51
+ usage: 'tree [folder] [depth]', summary: 'The file tree, with entries and hubs marked.',
52
+ run: (ctx, args) => {
53
+ // A lone number is the depth, not a folder called "2". `tree 3` is what
54
+ // everyone types when they want to see more; making them spell
55
+ // `tree . 3` would be pedantry in a tool built to be forgiving. `.` is the
56
+ // root, so it must not be taken as a folder name either.
57
+ const onlyDepth = args.length === 1 && /^\d+$/.test(args[0]);
58
+ const sub = onlyDepth ? '' : (args[0] || '');
59
+ return V.tree(ctx.repo, {
60
+ sub: sub === '.' ? '' : sub,
61
+ depth: onlyDepth ? Number(args[0]) : (Number(args[1]) || 2),
62
+ });
63
+ },
64
+ },
65
+ {
66
+ name: 'find', aliases: ['search', 'grep'], group: 'navigate',
67
+ usage: 'find <query>', summary: 'Search. Supports ext:js, -exclude, "phrases", /regex/.',
68
+ run: (ctx, args) => V.find(ctx.repo, { query: args.join(' ') }),
69
+ },
70
+ {
71
+ name: 'show', aliases: ['open', 'cat', 'read'], group: 'navigate',
72
+ usage: 'show <file> [from] [count]', summary: 'Read a file. Name it loosely: `show logger.js` works.',
73
+ run: (ctx, args) => V.show(ctx.repo, {
74
+ target: args[0] || '',
75
+ from: lineNumber(args[1], 0),
76
+ count: lineNumber(args[2], 0),
77
+ }),
78
+ },
79
+ {
80
+ name: 'deps', aliases: ['connections'], group: 'navigate',
81
+ usage: 'deps <file>', summary: 'What a file imports, and what imports it.',
82
+ run: (ctx, args) => V.deps(ctx.repo, { target: args.join(' ') }),
83
+ },
84
+ {
85
+ name: 'health', aliases: ['grade'], group: 'analyze',
86
+ usage: 'health', summary: 'Health grade, riskiest files, debt.',
87
+ run: (ctx) => V.health(ctx.repo),
88
+ },
89
+ {
90
+ name: 'hubs', aliases: ['core'], group: 'analyze',
91
+ usage: 'hubs', summary: 'The most depended-on files.',
92
+ run: (ctx) => V.hubs(ctx.repo),
93
+ },
94
+ {
95
+ name: 'layers', aliases: ['depth'], group: 'analyze',
96
+ usage: 'layers', summary: 'Import depth, shallowest first.',
97
+ run: (ctx) => V.layers(ctx.repo),
98
+ },
99
+ {
100
+ name: 'patterns', aliases: ['architecture'], group: 'analyze',
101
+ usage: 'patterns', summary: 'What the architecture looks like, in prose.',
102
+ run: (ctx) => V.patterns(ctx.repo),
103
+ },
104
+ {
105
+ name: 'stats', aliases: ['numbers'], group: 'analyze',
106
+ usage: 'stats', summary: 'Languages, biggest folders and files, complexity.',
107
+ run: (ctx) => V.stats(ctx.repo),
108
+ },
109
+ {
110
+ name: 'security', aliases: ['audit'], group: 'analyze',
111
+ usage: 'security', summary: 'Heuristic findings from the built-in rules.',
112
+ run: (ctx) => V.security(ctx.repo),
113
+ },
114
+ {
115
+ name: 'stack', aliases: ['packages'], group: 'analyze',
116
+ usage: 'stack', summary: 'Declared dependencies and frameworks.',
117
+ run: (ctx) => V.stack(ctx.repo),
118
+ },
119
+ {
120
+ name: 'entry', aliases: ['entries'], group: 'analyze',
121
+ usage: 'entry', summary: 'Recognized entry points and how far they reach.',
122
+ run: (ctx) => V.entry(ctx.repo),
123
+ },
124
+ {
125
+ name: 'externals', aliases: ['drift'], group: 'analyze',
126
+ usage: 'externals', summary: 'External packages, plus dependency drift.',
127
+ run: (ctx) => V.externals(ctx.repo),
128
+ },
129
+ {
130
+ name: 'graph', aliases: ['tree-graph'], group: 'navigate',
131
+ usage: 'graph <file> [depth]', summary: 'The dependency tree around a file, as arrows.',
132
+ run: (ctx, args) => A.graph(ctx.repo, { target: args[0] || '', depth: Number(args[1]) || 2 }),
133
+ },
134
+ {
135
+ name: 'blast', aliases: ['impact', 'radius'], group: 'navigate',
136
+ usage: 'blast <file>', summary: 'What breaks if this file breaks, transitively.',
137
+ run: (ctx, args) => A.blast(ctx.repo, { target: args.join(' ') }),
138
+ },
139
+ {
140
+ name: 'symbols', aliases: ['outline', 'functions'], group: 'navigate',
141
+ usage: 'symbols <file>', summary: 'Functions, classes and exports in a file.',
142
+ run: (ctx, args) => A.symbols(ctx.repo, { target: args.join(' ') }),
143
+ },
144
+ {
145
+ name: 'docs', aliases: ['document'], group: 'analyze',
146
+ usage: 'docs [file|folder] | docs --write', summary: 'Prose docs for a file, or write ONBOARDER.md.',
147
+ run: async (ctx, args) => {
148
+ // `--write` is the one command here that touches the filesystem. It is
149
+ // opt-in, explicit, and prints where it wrote — a map tool should never
150
+ // leave a file behind just because someone asked what the docs say.
151
+ if (args[0] === '--write') {
152
+ const abs = await A.writeDocs(ctx.repo, args[1] || 'ONBOARDER.md');
153
+ return ' ' + ok('Wrote ') + cyan(abs);
154
+ }
155
+ return A.docs(ctx.repo, { target: args.filter((a) => a !== '--write').join(' ') });
156
+ },
157
+ },
158
+ {
159
+ name: 'log', aliases: ['history', 'commits'], group: 'git',
160
+ usage: 'log [count]', summary: 'Recent commits, and who wrote them.',
161
+ run: (ctx, args) => A.log(ctx.repo, { limit: Number(args[0]) || 15 }),
162
+ },
163
+ {
164
+ name: 'hotspots', aliases: ['churn'], group: 'git',
165
+ usage: 'hotspots [count]', summary: 'Files that are both complex and frequently changed.',
166
+ run: (ctx, args) => A.hotspots(ctx.repo, { limit: Number(args[0]) || 12 }),
167
+ },
168
+ {
169
+ name: 'blame', aliases: ['authors'], group: 'git',
170
+ usage: 'blame <file>', summary: 'Who wrote each line of a file.',
171
+ run: (ctx, args) => A.blame(ctx.repo, { target: args.join(' ') }),
172
+ },
173
+ {
174
+ name: 'coupling', aliases: ['matrix', 'heatmap'], group: 'analyze',
175
+ usage: 'coupling', summary: 'Folder-to-folder import traffic, as a heat grid.',
176
+ run: (ctx) => A.coupling(ctx.repo),
177
+ },
178
+ {
179
+ name: 'clusters', aliases: ['communities', 'modules'], group: 'analyze',
180
+ usage: 'clusters', summary: 'Groups of files that import each other more than the rest.',
181
+ run: (ctx) => A.clusters(ctx.repo),
182
+ },
183
+ {
184
+ name: 'diagram', aliases: ['mermaid'], group: 'analyze',
185
+ usage: 'diagram [file]', summary: 'Mermaid source for the repo, or one file.',
186
+ run: (ctx, args) => A.diagram(ctx.repo, { target: args.join(' ') }),
187
+ },
188
+ {
189
+ name: 'layers-diagram', aliases: [], group: 'analyze',
190
+ usage: 'layers-diagram', summary: 'Mermaid source for the layer stack.',
191
+ run: (ctx) => A.layerDiagram(ctx.repo),
192
+ },
193
+ {
194
+ name: 'risks', aliases: ['problems', 'smells'], group: 'analyze',
195
+ usage: 'risks', summary: 'Cycles, orphans, dead exports, drift — in one list.',
196
+ run: (ctx) => A.risks(ctx.repo),
197
+ },
198
+ {
199
+ name: 'about', aliases: ['version'], group: 'session',
200
+ usage: 'about', summary: 'Version, repo path, and what is indexed.',
201
+ run: (ctx) => V.about(ctx.repo, ctx.version),
202
+ },
203
+ {
204
+ name: 'rescan', aliases: ['reload'], group: 'session',
205
+ usage: 'rescan', summary: 'Re-read the repo from disk.',
206
+ run: (ctx) => ctx.rescan(),
207
+ },
208
+ {
209
+ name: 'github', aliases: ['remote', 'repo'], group: 'session',
210
+ usage: 'github', summary: "What GitHub says about this repo — stars, issues, license.",
211
+ run: (ctx) => ctx.github(),
212
+ },
213
+ {
214
+ name: 'cd', aliases: ['open-repo', 'use'], group: 'session',
215
+ usage: 'cd <folder|url>', summary: 'Load another repository, or clone one by URL.',
216
+ run: (ctx, args) => ctx.loadRepo(args.join(' ')),
217
+ },
218
+ {
219
+ name: 'web', aliases: ['site', 'serve'], group: 'session',
220
+ usage: 'web', summary: 'Start the web UI and print its URL.',
221
+ run: (ctx) => ctx.web(),
222
+ },
223
+ {
224
+ name: 'clear', aliases: ['cls'], group: 'session',
225
+ usage: 'clear', summary: 'Clear the screen.',
226
+ run: () => CLEAR,
227
+ },
228
+ {
229
+ name: 'exit', aliases: ['quit', 'q'], group: 'session',
230
+ usage: 'exit', summary: 'Leave. Ctrl-D does the same.',
231
+ run: () => EXIT,
232
+ },
233
+ ];
234
+
235
+ // One lookup for every spelling a person might type, built once at import.
236
+ const BY_NAME = new Map();
237
+ for (const cmd of COMMANDS) {
238
+ BY_NAME.set(cmd.name, cmd);
239
+ for (const a of cmd.aliases) BY_NAME.set(a, cmd);
240
+ }
241
+
242
+ export function lookup(word) {
243
+ return BY_NAME.get(String(word || '').toLowerCase());
244
+ }
245
+
246
+ // Every canonical name, for "did you mean" matching. Aliases are deliberately
247
+ // excluded: suggesting `open` when someone typed `sho` is less useful than
248
+ // suggesting `show`, which is the word they were reaching for.
249
+ export function commandNames() {
250
+ return COMMANDS.map((c) => c.name);
251
+ }
252
+
253
+ // `help <command>` answers about one command; bare `help` lists them, grouped so
254
+ // the shape of the tool is visible rather than alphabetical. Every row is
255
+ // fitted, because a long summary on a narrow terminal used to wrap into the
256
+ // next row and make the list unreadable.
257
+ export function helpText(ctx, topic = '') {
258
+ const w = termWidth();
259
+ if (topic) {
260
+ const cmd = lookup(topic);
261
+ if (!cmd) return fit(` No command called "${topic}". Try \`help\`.`, Math.max(10, w - 2));
262
+ const also = cmd.aliases.length ? ` (also: ${cmd.aliases.join(', ')})` : '';
263
+ return [
264
+ fit(' ' + cmd.usage + also, Math.max(10, w - 2)),
265
+ wrapText(cmd.summary, ' '),
266
+ ].join('\n');
267
+ }
268
+ const groups = new Map();
269
+ for (const cmd of COMMANDS) {
270
+ if (!groups.has(cmd.group)) groups.set(cmd.group, []);
271
+ groups.get(cmd.group).push(cmd);
272
+ }
273
+ // Two cells of indent, two of gap, and the summary gets whatever is left. The
274
+ // usage column shrinks with the terminal rather than pushing the text off it.
275
+ const longest = Math.max(...COMMANDS.map((c) => c.usage.length));
276
+ const usageRoom = Math.max(8, Math.min(longest, Math.floor(w * 0.4)));
277
+ const out = [];
278
+ for (const [group, list] of groups) {
279
+ out.push(fit(' ' + group.toUpperCase(), Math.max(10, w - 2)));
280
+ for (const c of list) {
281
+ const left = ' ' + fit(c.usage, usageRoom);
282
+ const room = w - left.length - 2;
283
+ out.push(room > 8 ? left + ' ' + fit(c.summary, room, { tail: false }) : left);
284
+ }
285
+ }
286
+ out.push('');
287
+ out.push(fit(' ' + (ctx?.repo ? ctx.repo.name : 'onboarder')
288
+ + ' · Ctrl-D or `exit` to leave · Ctrl-C twice to quit', Math.max(10, w - 2)));
289
+ return out.join('\n');
290
+ }
291
+
292
+ // Split a typed line into words, honoring quotes so `find "exact phrase"` and
293
+ // `show "my file.js"` arrive as one argument each. Done once, here, so no
294
+ // command has to re-implement it.
295
+ export function tokenize(line) {
296
+ const out = [];
297
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
298
+ let m;
299
+ while ((m = re.exec(String(line || '')))) out.push(m[1] ?? m[2] ?? m[3]);
300
+ return out;
301
+ }
302
+
303
+ // Tab completion, because a REPL where you retype `shared/analyzer/graph.js` one
304
+ // letter at a time is not a REPL, it is a punishment.
305
+ //
306
+ // Two contexts, chosen by what is already typed. Before the first space, the line
307
+ // is a command name, so it completes against the command table — including
308
+ // aliases, because a person who types `ls` wants `tree` to finish. After a space,
309
+ // it is an argument, so it completes against real file paths in the loaded repo,
310
+ // which is the thing that is tedious to type and impossible to remember.
311
+ //
312
+ // `shellEscape` is the other half of a good REPL: `!git status` runs a shell
313
+ // command and hands the output back, so nobody has to leave the session to run
314
+ // one thing. It is deliberately the *only* way out to a shell, so the set of
315
+ // things that can happen in a session stays legible.
316
+ function completer(repo) {
317
+ return function complete(line) {
318
+ const trailingSpace = /\s$/.test(line);
319
+ const parts = line.split(/\s+/);
320
+ // Completing a command (no space yet, or trailing space after one word).
321
+ if (parts.length <= 1 || (parts.length === 2 && trailingSpace)) {
322
+ const hits = [...BY_NAME.keys()].filter((n) => n.startsWith(parts[0] || '')).sort();
323
+ return [hits.length ? hits : parts, parts[0] || ''];
324
+ }
325
+ // Completing a path argument against the loaded repo. Matches at any depth,
326
+ // not just from the root: `show rend` should find `src/render.js`, which is
327
+ // the case the resolver's forgiving path lookup already handles — the
328
+ // completer has to agree with it or Tab contradicts what the command does.
329
+ const frag = parts[parts.length - 1] || '';
330
+ const slash = frag.lastIndexOf('/');
331
+ const dir = slash === -1 ? '' : frag.slice(0, slash + 1);
332
+ const base = slash === -1 ? frag : frag.slice(slash + 1);
333
+ const seen = new Set();
334
+ const hits = [];
335
+ for (const f of repo.scan.allFiles || repo.scan.files) {
336
+ if (dir && !f.startsWith(dir)) continue;
337
+ // With no directory typed, match the *last* segment so `rend` finds
338
+ // `src/render.js`; with one typed, match the segment being completed.
339
+ const name = dir ? f.slice(dir.length) : f.slice(f.lastIndexOf('/') + 1);
340
+ if (!name.startsWith(base)) continue;
341
+ if (seen.has(name)) continue;
342
+ seen.add(name);
343
+ hits.push(name);
344
+ if (hits.length >= 200) break;
345
+ }
346
+ return [hits.length ? hits : [frag], frag];
347
+ };
348
+ }
349
+
350
+ // Run a shell command and capture its output. A non-zero exit is not a crash —
351
+ // `grep` that finds nothing is a normal answer — so the code is reported, not
352
+ // thrown. There is no `shell: true`: the whole line is the argument vector, so
353
+ // nothing in a filename can be interpreted as a second command.
354
+ async function shellEscape(line, cwd) {
355
+ const parts = tokenize(line);
356
+ if (!parts.length) return null;
357
+ try {
358
+ const { stdout, stderr } = await run(parts[0], parts.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
359
+ const out = String(stdout || '').trimEnd();
360
+ const err = String(stderr || '').trimEnd();
361
+ return [out, err].filter(Boolean).join('\n') || dim(' (no output)');
362
+ } catch (e) {
363
+ const code = e.code ?? e.status;
364
+ const err = String(e.stderr || '').trimEnd() || String(e.message || '').trimEnd();
365
+ return dim(' exit ' + (code ?? '?') + (err ? '\n ' + err : ''));
366
+ }
367
+ }
368
+
369
+ export { completer, shellEscape };
@@ -0,0 +1,75 @@
1
+ // Asking GitHub what it knows about a repository — the terminal half of the
2
+ // feature the web app calls "From the remote".
3
+ //
4
+ // The `fetch` lives here rather than in `shared/analyzer/github.js` because it
5
+ // can only happen in one runtime at a time, and because this side can do
6
+ // something the browser cannot: send a token. `GITHUB_TOKEN` (or `GH_TOKEN`, the
7
+ // name the `gh` CLI uses) turns GitHub's 60-requests-an-hour guest allowance
8
+ // into 5,000. The browser has nowhere to keep a secret, which is precisely why
9
+ // this is better in a terminal than on the web.
10
+ //
11
+ // A failed lookup is never fatal. `github` on a repo with no remote, a private
12
+ // one, or a machine with no network is a normal thing to type, so every path
13
+ // returns the honest reason instead of throwing.
14
+
15
+ import { githubRepoPath, remoteError, remoteFacts } from '../../shared/analyzer/github.js';
16
+
17
+ const API = 'https://api.github.com/repos/';
18
+ const TIMEOUT_MS = 8000;
19
+
20
+ // The token is read per call rather than at import: a session that exports one
21
+ // mid-flight should not need to be restarted to pick it up.
22
+ function token() {
23
+ const t = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
24
+ return typeof t === 'string' && t.trim() ? t.trim() : '';
25
+ }
26
+
27
+ /**
28
+ * Look up a GitHub repository's public facts.
29
+ *
30
+ * `fetchImpl` is a parameter so this is testable without a network, and so the
31
+ * caller can pass an already-aborted-aware fetch if it has one.
32
+ *
33
+ * Returns `{ ok: true, facts, repoPath }` or `{ ok: false, reason }`. Never
34
+ * throws — the reason is the return value, because every failure here is
35
+ * something to print, not something to crash a session over.
36
+ */
37
+ export async function fetchRepoFacts(url, { fetchImpl = globalThis.fetch } = {}) {
38
+ const repoPath = githubRepoPath(url);
39
+ if (!repoPath) {
40
+ return { ok: false, reason: 'That is not a GitHub URL, so there are no remote facts to ask for.' };
41
+ }
42
+ if (typeof fetchImpl !== 'function') {
43
+ return { ok: false, reason: 'This runtime has no fetch, so GitHub cannot be asked.' };
44
+ }
45
+
46
+ const headers = { accept: 'application/vnd.github+json', 'user-agent': 'onboarder' };
47
+ const auth = token();
48
+ if (auth) headers.authorization = 'Bearer ' + auth;
49
+
50
+ let res;
51
+ try {
52
+ res = await fetchImpl(API + repoPath, {
53
+ headers,
54
+ signal: AbortSignal.timeout(TIMEOUT_MS),
55
+ });
56
+ } catch (err) {
57
+ // A timeout and a refused connection are the same thing to the person who
58
+ // typed the command: it did not come back. The message is kept because
59
+ // "GitHub could not be reached" and "the certificate is not trusted" are
60
+ // very different problems on very different machines.
61
+ const detail = /timeout|abort/i.test(err?.message || '') ? ' (it took too long)' : '';
62
+ return { ok: false, reason: 'Could not reach GitHub' + detail + ' — ' + (err?.message || err) };
63
+ }
64
+
65
+ if (!res.ok) return { ok: false, reason: remoteError(res.status) };
66
+
67
+ let facts;
68
+ try {
69
+ facts = remoteFacts(await res.json());
70
+ } catch {
71
+ return { ok: false, reason: 'GitHub sent something that is not JSON.' };
72
+ }
73
+ if (!facts) return { ok: false, reason: 'GitHub sent no repository facts.' };
74
+ return { ok: true, facts, repoPath };
75
+ }
@@ -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
+ }