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.
@@ -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
+ }
@@ -17,17 +17,82 @@ import { nodeFileSource } from '../../server/fileSourceNode.js';
17
17
  import { buildSearchIndex } from '../../server/searchIndex.js';
18
18
  import { searchDocuments } from '../../server/apiSearch.js';
19
19
  import { expandHome, resolveInside } from '../../server/paths.js';
20
+ import { assertGitUrl, cloneRepo, removeClone, repoNameFromUrl } from '../../server/gitClone.js';
21
+ import { gitRemoteOrigin } from '../../server/gitRemote.js';
20
22
  import { scanRepo } from '../../shared/analyzer/scan.js';
21
23
  import { detectManifest } from '../../shared/analyzer/services.js';
22
24
  import { computeFacts, roleOf } from '../../shared/analyzer/graph.js';
23
25
  import { analyzeHealth } from '../../shared/analyzer/health.js';
24
- import { computeLayers, detectPatterns } from '../../shared/analyzer/patterns.js';
26
+ import { computeLayers, detectPatterns, couplingMatrix } from '../../shared/analyzer/patterns.js';
25
27
  import { summarizeSecurity } from '../../shared/analyzer/security.js';
26
28
  import { analyzeStack } from '../../shared/analyzer/stack.js';
27
29
  import { buildTourStops } from '../../shared/analyzer/tour.js';
28
30
  import { explainFile, explainFolder, explainOverview } from '../../shared/analyzer/explainLocal.js';
29
31
  import { languageLabel } from '../../shared/analyzer/languages/index.js';
30
32
 
33
+ // Does this argument name a repository to fetch, rather than a folder on this
34
+ // machine? The check is deliberately narrow: only the URL shapes git itself
35
+ // accepts, so a folder that happens to be called `ssh://stuff` cannot exist on a
36
+ // filesystem anyway, while `owner/repo` is NOT accepted — too many real folders
37
+ // are named like that (`src/server`, `app/models`) and silently cloning
38
+ // `github.com/src/server` because someone typed `cd src/server` would be a
39
+ // genuinely bad failure. The shorthand, if it is wanted, has to be asked for.
40
+ export function isGitUrl(target) {
41
+ return /^(https?:\/\/|git@|ssh:\/\/)/.test(String(target || '').trim());
42
+ }
43
+
44
+ // Open a local folder or clone a remote one — the same three ways in the web
45
+ // app's landing page offers, narrowed to the two that make sense for a command
46
+ // line. A clone is left in place when the session ends; `closeRemote` removes it,
47
+ // and the session calls that on `cd` and on exit, so nothing is orphaned in the
48
+ // temp dir.
49
+ export async function openRepoOrClone(target, { onProgress } = {}) {
50
+ const raw = String(target || '').trim();
51
+ if (!isGitUrl(raw)) return openRepo(raw, { onProgress });
52
+
53
+ const url = assertGitUrl(raw);
54
+ if (onProgress) onProgress({ phase: 'clone', url });
55
+ const clone = await cloneRepo(url);
56
+ try {
57
+ const repo = await openRepo(clone.dir, { onProgress });
58
+ // The repo's own name is what a person recognizes; the temp path is kept so
59
+ // `about` can still print a path someone could go and look at.
60
+ return {
61
+ ...repo,
62
+ name: repoNameFromUrl(url) || repo.name,
63
+ gitUrl: url,
64
+ tempId: path.basename(clone.dir),
65
+ cloneDir: clone.dir,
66
+ };
67
+ } catch (err) {
68
+ // A clone that was never loaded has no session to clean it up later, so it
69
+ // has to go now or it is a temp directory nobody owns.
70
+ await removeClone(clone.dir);
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ // The URL this repo should be asked about on GitHub, or null.
76
+ //
77
+ // A repo this session cloned knows its own URL. A repo someone was standing in
78
+ // does not, and git does. Asking git here rather than in the `github` command
79
+ // keeps the answer on the repo object, so every consumer gets it the same way.
80
+ export async function remoteUrlFor(repo) {
81
+ if (repo.gitUrl) return repo.gitUrl;
82
+ if (repo.remoteChecked) return null;
83
+ repo.remoteChecked = true;
84
+ const found = await gitRemoteOrigin(repo.root);
85
+ if (found.ok) repo.gitUrl = found.url;
86
+ return repo.gitUrl || null;
87
+ }
88
+
89
+ // Drop a temp clone, if this repo is one. Safe to call on a local repo: it
90
+ // checks the shape of the directory rather than trusting a flag, so a bug
91
+ // elsewhere cannot turn "clean up" into "delete the folder the user is in".
92
+ export async function closeRemote(repo) {
93
+ if (repo?.cloneDir) await removeClone(repo.cloneDir);
94
+ }
95
+
31
96
  // Scan a directory and return the loaded repo, or throw an Error whose message
32
97
  // is safe to print straight to the user. `target` may be `~`, a relative path,
33
98
  // or anything `expandHome` understands; it defaults to the current directory.
@@ -45,7 +110,9 @@ export async function openRepo(target = '.', { onProgress } = {}) {
45
110
 
46
111
  // The layers projection is an input to patterns, not a parallel view of the
47
112
  // same thing — `detectPatterns` reads the layer depths to place a file in the
48
- // architecture. Compute once, share.
113
+ // architecture. Compute once, share. The coupling matrix is the same kind of
114
+ // thing: a projection of `facts.folderEdges` that both the site and the
115
+ // terminal's heat grid read, so it is computed once here rather than per view.
49
116
  const layers = computeLayers(scan, facts);
50
117
 
51
118
  return {
@@ -56,6 +123,7 @@ export async function openRepo(target = '.', { onProgress } = {}) {
56
123
  manifest,
57
124
  searchIndex,
58
125
  layers,
126
+ coupling: couplingMatrix(scan, facts),
59
127
  health: analyzeHealth(scan, facts),
60
128
  patterns: detectPatterns(scan, facts, manifest, layers),
61
129
  security: summarizeSecurity(scan),
@@ -83,6 +151,12 @@ export function resolveTarget(repo, typed) {
83
151
  const raw = String(typed || '').trim().replace(/^\.\//, '');
84
152
  if (!raw) return { error: 'Name a file or folder.' };
85
153
 
154
+ // `.` and `./` are the root, which is a real folder and a common thing to type
155
+ // (`tree .`, `explain .`). Without this they fell through to the substring
156
+ // match and offered eight unrelated files as "did you mean", which is worse
157
+ // than useless — it is actively misleading. The root folder's path is `''`.
158
+ if (raw === '.' || raw === './') return { folder: ROOT_FOLDER };
159
+
86
160
  const files = repo.scan.files;
87
161
  if (!repo.byPath) repo.byPath = new Map(files.map((f) => [f.path, f]));
88
162
  if (repo.byPath.has(raw)) return { file: repo.byPath.get(raw) };
@@ -107,11 +181,16 @@ export function resolveTarget(repo, typed) {
107
181
  return { error: 'Nothing in this repo matches "' + raw + '".' };
108
182
  }
109
183
 
184
+ // The root folder is a real answer, not a sentinel: `tree .` and `explain .`
185
+ // both need one, and it carries the same fields `resolveTarget` returns for any
186
+ // other folder so the callers need no special case.
187
+ const ROOT_FOLDER = Object.freeze({ name: '', path: '', loc: 0, comment: 0, blank: 0, size: 0, langs: {} });
188
+
110
189
  function folderFor(repo, typed) {
111
190
  const clean = typed.replace(/\/+$/, '');
112
- if (repo.scan.folders.some((d) => d.path === clean)) {
113
- return repo.scan.folders.find((d) => d.path === clean);
114
- }
191
+ if (clean === '') return ROOT_FOLDER;
192
+ const exact = repo.scan.folders.find((d) => d.path === clean);
193
+ if (exact) return exact;
115
194
  const matches = repo.scan.folders.filter((d) => d.path.endsWith('/' + clean) || d.name === clean);
116
195
  return matches.length === 1 ? matches[0] : null;
117
196
  }
@@ -134,6 +213,17 @@ export function searchRepo(repo, query, { limit = 12 } = {}) {
134
213
  return searchDocuments(repo.searchIndex, query, { limit });
135
214
  }
136
215
 
216
+ // A number a person typed, or nothing. `show f 1.5 2.5` used to print
217
+ // `1.5–3 of 344`: `Number('1.5')` passed the `|| 1` fallback, the label
218
+ // interpolated the fraction, and only `slice()` coerced it. A line range is
219
+ // either a whole number of lines or it is a mistake, and a mistake gets a
220
+ // message rather than a wrong heading.
221
+ export function lineNumber(value, fallback) {
222
+ if (value === undefined || value === null || value === '') return fallback;
223
+ const n = Number(value);
224
+ return Number.isInteger(n) && n > 0 ? n : null;
225
+ }
226
+
137
227
  export function explainRepoFile(repo, file) {
138
228
  return explainFile(file.path, file, repo.facts);
139
229
  }