codebase-onboarder 0.4.0 → 0.4.1

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 CHANGED
@@ -242,12 +242,34 @@ onboarder stop # stop it
242
242
  Log lines are column-aligned — a fixed-width local timestamp, a fixed-width level, then the message — so a wall of requests stays scannable:
243
243
 
244
244
  ```
245
- 16:09:38.985 INFO GET /api/health 200 (1ms)
246
- 16:09:46.460 INFO GET /nope 404 (1ms)
247
- 16:10:21.925 WARN config port=4310 reason="already in use"
245
+ 16:09:38 INFO GET /api/scan 200 (1.2s)
246
+ 16:09:46 INFO POST /api/auth/login 200 (2ms)
247
+ 16:10:03 WARN could not write the run record error=EACCES
248
248
  ```
249
249
 
250
- Set `ONBOARDER_LOG=json` for one JSON object per line instead (the log file is plain text by default so it stays readable; `--json` on `status`/`logs` is the machine-readable surface).
250
+ ### What gets logged (and what does not)
251
+
252
+ A single page load pulls ~90 ES modules, a stylesheet, and two vendored libraries. Logging each one buries every real event — a scan, a login, a 500 — under a hundred lines that describe nobody doing anything, repeated on every reload. So requests are classified:
253
+
254
+ | Request | Logged? |
255
+ |---|---|
256
+ | `GET /api/…` (a scan, a login, a settings save) | **yes** — this is a person doing something |
257
+ | Any `POST`/`PUT`/`DELETE` | **yes**, whatever the path |
258
+ | Any `4xx` or `5xx` | **yes** — a missing asset is a broken build, a 500 is a bug |
259
+ | `GET /api/health` (uptime poll) | no — it is not an event |
260
+ | `GET /app.js`, `/js/tree.js`, `/styles.css`, `/vendor/…` | **counted, not printed** |
261
+
262
+ Suppressed assets are not thrown away. Every 40 of them, one dim footnote is printed, so an idle terminal still says what it served rather than looking dead:
263
+
264
+ ```
265
+ 16:09:38 served 40 static files in 1.9s
266
+ ```
267
+
268
+ `LOG_LEVEL=debug` (or `ONBOARDER_LOG_VERBOSE=1`) turns every request back on for when you are debugging the server rather than watching it. `ONBOARDER_LOG=json` switches the format to one JSON object per line for anything parsing the log.
269
+
270
+ ### Terminal width
271
+
272
+ Every panel and log line is fitted to the terminal it is printed into, and a foreground `onboarder start` redraws its panel on `SIGWINCH` — resize the window and the border stays on screen instead of hanging off the edge. Long values are elided in the middle (the tail of a path is the part that identifies it), and below 52 columns the level column is dropped to make room for the message.
251
273
 
252
274
  ### Starting at login
253
275
 
@@ -297,6 +319,7 @@ codebase-onboarder/
297
319
  │ ├── daemon.js # Detached background start, log file, readiness probe
298
320
  │ ├── startup.js # Login items: launchd plist / systemd --user unit / Startup folder
299
321
  │ ├── logger.js # Aligned-text or JSON log lines from one entry shape
322
+ │ ├── layout.js # Shared terminal geometry: width, fit, panel, resize
300
323
  │ ├── router.js # Route table, live per-request settings, auth & CSRF gates
301
324
  │ ├── auth.js # Signed HttpOnly browser sessions
302
325
  │ ├── apiAuth.js # Login/status/logout endpoints
package/cli/commands.js CHANGED
@@ -182,6 +182,34 @@ function printServerDetails(out, started, options) {
182
182
  out('');
183
183
  }
184
184
 
185
+ // Keep a foreground panel at the terminal's current width.
186
+ //
187
+ // Resizing a window after the server started used to leave a panel whose right
188
+ // border was off-screen — the information was correct but unreadable, which is
189
+ // the same complaint as "it messes up the terminal". On SIGWINCH we redraw the
190
+ // block in place: move the cursor up over the lines we own, clear them, and
191
+ // reprint at the new width. Only the panel is redrawn, not the log scrollback
192
+ // above it, so nothing the user has already read is disturbed.
193
+ //
194
+ // A non-TTY (a pipe, a log file) never redraws: there is no cursor to move and
195
+ // re-printing would just duplicate the block.
196
+ export function watchResize(render, stream = process.stdout) {
197
+ if (!stream.isTTY || typeof process.stdout.on !== 'function') return () => {};
198
+ let previous = '';
199
+ const onResize = () => {
200
+ const next = render();
201
+ if (next === previous) return;
202
+ const lines = previous ? previous.split('\n').length : 0;
203
+ // Up over the old block, clear it, print the new one. `\x1b[J` clears from
204
+ // the cursor to the end of the screen, which is exactly the old block.
205
+ stream.write(lines ? `\x1b[${lines}A\x1b[J` : '');
206
+ stream.write(next);
207
+ previous = next;
208
+ };
209
+ process.stdout.on('SIGWINCH', onResize);
210
+ return () => process.stdout.removeListener('SIGWINCH', onResize);
211
+ }
212
+
185
213
  export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
186
214
  const file = flags.config || configPath();
187
215
  if (!await configExists(file)) {
@@ -220,6 +248,9 @@ export async function runStart({ flags = {}, out = console.log, err = console.er
220
248
  // and ONBOARDER_BACKGROUND is what tells the two apart: one is attached to a
221
249
  // terminal you are about to close, the other is already detached from it.
222
250
  printServerDetails(out, started, { configFile: file, mode: 'foreground' });
251
+ // Redraw the panel when the terminal is resized, so the border stays on screen
252
+ // and long paths re-elide to the new width instead of hanging off the edge.
253
+ watchResize(() => '\n' + panel('Onboarder is running', serverDetails(started, { configFile: file }).rows) + '\n');
223
254
  if (process.env.ONBOARDER_LAUNCH) {
224
255
  // Started by launchd/systemd/the Startup folder: these lines are going into
225
256
  // a log file nobody is watching, so they say what the supervisor is doing
package/cli/ui.js CHANGED
@@ -54,36 +54,20 @@ export const tick = ok(' ✓ ');
54
54
  export const cross = bad(' ✗ ');
55
55
  export const dash = dim(' – ');
56
56
 
57
- // Visible width, ANSI codes excluded. Padding has to be computed on what the
58
- // terminal *shows*, not on the bytes we wrote, or every colored row grows a
59
- // few columns and the column stops being a column.
60
- export function width(text) {
61
- return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
62
- }
63
-
64
- // A titled block of rows. The box is drawn from the widest row rather than a
65
- // fixed 80 columns, so it stays aligned in a narrow terminal and does not stretch
66
- // across a wide one.
67
- export function panel(title, rows, { indent = ' ' } = {}) {
68
- const labelWidth = Math.max(...rows.map((r) => width(r.label ?? '')), 0);
69
- // Every row starts two columns in; the label column is then as wide as the
70
- // longest label, so all the values line up regardless of label length.
71
- const body = rows.map((r) => r.hint
72
- ? ' ' + ' '.repeat(labelWidth + 4) + dim(r.hint)
73
- : ' ' + paint((r.label ?? '').padEnd(labelWidth), 'gray') + ' ' + (r.value ?? ''));
74
- // The frame is sized from its contents: a long path widens the box instead of
75
- // spilling out of it, and a short one does not stretch to 80 columns.
76
- const inner = Math.max(title.length + 4, ...body.map(width), 24);
77
- const top = '┌─ ' + bold(title) + ' ' + '─'.repeat(Math.max(1, inner - title.length - 3)) + '┐';
78
- const bottom = '└' + '─'.repeat(inner) + '┘';
79
- return [top, ...body, bottom].map((line) => indent + (line === top || line === bottom ? paint(line, 'gray') : line)).join('\n');
80
- }
57
+ // The layout math (width, fit, panel) lives in `server/layout.js` so the server's
58
+ // own startup banner can use the identical geometry without importing this file
59
+ // and inverting the dependency. This module is only the *color* on top of it.
60
+ import { width, fit, termWidth, panel as layoutPanel, hint, row } from '../server/layout.js';
81
61
 
82
- // A one-line hint under a row, wrapped in the panel's dim voice.
83
- export const hint = (text) => ({ hint: text });
62
+ export { width, fit, termWidth, hint, row };
84
63
 
85
- // Multi-line values (a command, a path list) still align on the first line.
86
- export function row(label, value) {
87
- return { label, value: String(value) };
64
+ // A titled block of rows that fits the terminal it is printed into. Same shape
65
+ // the server banner uses; the difference is only that this one paints.
66
+ export function panel(title, rows, options = {}) {
67
+ const styles = { label: 'gray', frame: 'gray', hint: 'gray', title: 'bold' };
68
+ return layoutPanel(title, rows, {
69
+ ...options,
70
+ paint: (text, style) => paint(text, styles[style] || 'gray'),
71
+ });
88
72
  }
89
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-onboarder",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Drop a path. Get a map. A zero-dependency codebase visualizer with a CLI onboarding wizard, a web UI, and an optional key-gated self-hosted mode.",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.js CHANGED
@@ -25,6 +25,7 @@ import { browserUrl, configPath, isLoopbackHost, readSettings, serverUrls } from
25
25
  import { tunnelStatus } from './tunnel.js';
26
26
  import { pidIsAlive, readPidFile, removePidFile, writePidFile, writeRunInfo, runInfoPath } from './pidfile.js';
27
27
  import { logPath } from './daemon.js';
28
+ import { panel, row } from './layout.js';
28
29
 
29
30
  const logger = createLogger();
30
31
 
@@ -64,42 +65,45 @@ export function createServer(config = CONFIG) {
64
65
 
65
66
  // What the terminal shows once the socket is listening. A pure string builder
66
67
  // so the CLI prints exactly this too, and so a test can read it.
67
- export function startupBanner(settings, { configFile } = {}) {
68
+ //
69
+ // The shape is label/value rows, not prose, because every fact here has to be
70
+ // readable at a glance and copy-pasteable. The one long sentence it used to
71
+ // print — "self-hosted — the network can reach this; every API call needs the
72
+ // access key" — wrapped unpredictably at any terminal width and buried the URL
73
+ // people actually need. Now the detail lives on its own row.
74
+ export function startupBanner(settings, { configFile, columns } = {}) {
68
75
  const urls = serverUrls(settings);
69
- const lines = ['', ' Onboarder is up.'];
70
- lines.push(settings.mode === 'self-hosted'
71
- ? (isLoopbackHost(settings.host)
72
- ? ' Mode self-hosted — loopback bind; use a tunnel or change the host for direct network access'
73
- : ' Mode self-hosted — the network can reach this; every API call needs the access key')
74
- : ' Mode local — only this machine can reach it');
75
- lines.push(' Local ' + urls.local);
76
- if (urls.network) lines.push(' Network ' + urls.network);
77
- if (urls.domain) {
78
- lines.push(' Domain ' + urls.domain);
79
- if (settings.domain && !settings.https) {
80
- lines.push(' HTTPS disabled — `onboarder setup` or `onboarder https setup` enables trusted TLS');
81
- } else if (settings.https) {
82
- lines.push(' HTTPS Caddy obtains, renews, and terminates TLS for this domain');
83
- }
84
- }
85
- if (settings.mode === 'self-hosted' && settings.accessKey) {
86
- lines.push(' Remote browsers show an access-key sign-in page; the key is never put in the URL.');
76
+ const rows = [];
77
+ const add = (label, value) => rows.push([label, value]);
78
+
79
+ add('URL', urls.local);
80
+ if (urls.network) add('Network', urls.network);
81
+ if (urls.domain) add('Public', urls.domain);
82
+ add('Bind', `${settings.host}:${settings.port}`);
83
+ add('Mode', settings.mode === 'self-hosted'
84
+ ? (isLoopbackHost(settings.host) ? 'self-hosted (loopback — use a tunnel)' : 'self-hosted (network reachable)')
85
+ : 'local (this machine only)');
86
+
87
+ if (settings.mode === 'self-hosted') {
88
+ add('Auth', settings.accessKey
89
+ ? 'access key required for remote browsers'
90
+ : 'NO ACCESS KEY — every API call is refused');
87
91
  }
88
- if (settings.mode === 'self-hosted' && !settings.accessKey) {
89
- lines.push(' WARNING self-hosted with no access key — every API call is refused until one is set.');
90
- lines.push(' Run `onboarder setup` or `onboarder config key rotate`.');
92
+ if (settings.domain) {
93
+ add('HTTPS', settings.https ? 'Caddy terminates TLS for this domain' : 'off — `onboarder https setup` enables it');
91
94
  }
92
95
  const tunnels = tunnelStatus(settings);
93
96
  for (const name of ['cloudflare', 'tailscale']) {
94
97
  const t = tunnels[name];
95
98
  if (!t.enabled) continue;
96
- lines.push(t.installed
97
- ? ` Tunnel ${name}: ${t.command}`
98
- : ` Tunnel ${name} is enabled but its CLI is not installed — ${t.install}`);
99
+ add('Tunnel', t.installed ? `${name}: ${t.command}` : `${name} enabled, CLI missing — ${t.install}`);
99
100
  }
100
- if (configFile) lines.push(' Config ' + configFile);
101
- lines.push('');
102
- return lines.join('\n');
101
+ if (configFile) add('Config', configFile);
102
+
103
+ // Rendered through the same panel helper the CLI uses, so `node server/index.js`
104
+ // and `onboarder start` cannot drift apart — and so both adapt to the terminal
105
+ // width instead of assuming 80 columns.
106
+ return '\n' + panel('Onboarder is up', rows.map(([label, value]) => row(label, value)), { columns }) + '\n';
103
107
  }
104
108
 
105
109
  // Ask the OS to open the app. Best-effort and detached: a missing opener on a
@@ -0,0 +1,73 @@
1
+ // Terminal geometry, with no color and no dependencies.
2
+ //
3
+ // This lives here, under `server/`, because two very different callers need
4
+ // identical layout: the CLI (which paints) and the server's own startup banner
5
+ // (which must not import `cli/ui.js` — that would invert the dependency, since
6
+ // the CLI sits on top of the server). So the *shape* is here and the *color* is
7
+ // injected by the caller as a `paint(text, style)` function.
8
+ //
9
+ // Everything is pure string math on purpose: a layout decision is a thing you
10
+ // want to unit-test without spawning a terminal, and the whole reason panels
11
+ // used to look broken is that this math was scattered through the callers.
12
+
13
+ export function width(text) {
14
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
15
+ }
16
+
17
+ // The terminal we are drawing into, asked at call time rather than cached at
18
+ // import: a panel printed after the user resized their window should use the
19
+ // new size. Falls back to COLUMNS, then 80, so a pipe or a log file still gets
20
+ // sane output instead of `undefined`.
21
+ export function termWidth(stream = process.stdout) {
22
+ return stream.columns || Number(process.env.COLUMNS) || 80;
23
+ }
24
+
25
+ // Shorten to fit, with a real ellipsis. The middle is elided rather than the
26
+ // tail, because in a path or URL the end is the part that identifies it.
27
+ export function fit(text, max, { tail = true } = {}) {
28
+ const value = String(text);
29
+ if (max <= 0) return '';
30
+ if (value.length <= max) return value;
31
+ if (max === 1) return '…';
32
+ if (!tail) return value.slice(0, max - 1) + '…';
33
+ const head = Math.ceil((max - 1) * 0.4);
34
+ const rest = max - 1 - head;
35
+ return (head ? value.slice(0, head) + '…' : '…') + value.slice(value.length - rest);
36
+ }
37
+
38
+ // A titled block of rows that fits the terminal it is printed into. The frame is
39
+ // sized from its contents, then clamped to the available width, and every value
40
+ // is elided to fit rather than allowed to spill past the border — a panel whose
41
+ // right edge is off-screen is what makes output "mess the terminal".
42
+ //
43
+ // `paint` is injected: `(text, style) => string`, where style is 'label' or
44
+ // 'frame'. The default is identity, which is what the server banner and the
45
+ // `--no-color` path want.
46
+ export function panel(title, rows, { indent = ' ', columns, paint = (t) => String(t) } = {}) {
47
+ // The hard ceiling is the terminal. A floor below it would be a lie: on a 40
48
+ // column terminal a 59-wide panel is exactly the overflow this exists to
49
+ // prevent, so the floor only guards against a nonsensical zero, and short
50
+ // titles/values are handled by the `fit` calls below rather than by a minimum.
51
+ const available = Math.max(24, (columns || termWidth()) - indent.length);
52
+ const labelWidth = Math.min(12, Math.max(...rows.map((r) => width(r.label ?? '')), 0));
53
+ // indent(2) + gap(2) + label + gap(2) + value + right border(2)
54
+ const valueRoom = Math.max(8, available - 2 - labelWidth - 2 - 2);
55
+ const body = rows.map((r) => (r.hint
56
+ ? ' ' + ' '.repeat(labelWidth + 2) + paint(fit(r.hint, valueRoom), 'hint')
57
+ : ' ' + paint(fit(r.label ?? '', labelWidth).padEnd(labelWidth), 'label') + ' ' + fit(r.value ?? '', valueRoom)));
58
+
59
+ // Frame width = whatever the body needs, capped to what the terminal has.
60
+ const wanted = Math.max(...body.map(width), 12);
61
+ const inner = Math.max(8, Math.min(available - 2, wanted));
62
+ const shownTitle = fit(title, Math.max(2, inner - 4));
63
+ const top = paint('┌─ ', 'frame') + paint(shownTitle, 'title')
64
+ + ' ' + paint('─'.repeat(Math.max(0, inner - shownTitle.length - 3)) + '┐', 'frame');
65
+ const bottom = paint('└' + '─'.repeat(inner) + '┘', 'frame');
66
+ return [top, ...body, bottom].map((line) => indent + line).join('\n');
67
+ }
68
+
69
+ // A one-line note rendered in the panel's muted voice.
70
+ export const hint = (text) => ({ hint: text });
71
+
72
+ // A label/value row. Values are stringified here so a caller can pass a number.
73
+ export const row = (label, value) => ({ label, value: String(value) });
package/server/logger.js CHANGED
@@ -25,7 +25,38 @@ function paint(text, color, enabled) {
25
25
  }
26
26
 
27
27
  // Fields that are part of the envelope or already rendered into the message.
28
- const ENVELOPE = new Set(['ts', 'time', 'level', 'msg', 'method', 'path', 'status', 'ms', 'line', 'scope']);
28
+ // `summary` is here so the suppressed-noise footer does not print itself as
29
+ // `summary=true` — it is a rendering flag, not data.
30
+ const ENVELOPE = new Set(['ts', 'time', 'level', 'msg', 'method', 'path', 'status', 'ms', 'line', 'scope', 'summary']);
31
+
32
+ // Request classification — the difference between a person doing something and
33
+ // a browser fetching a file.
34
+ //
35
+ // This is the single most important thing about Onboarder's logs. A page load
36
+ // pulls ~90 ES modules, a stylesheet, and two vendored libraries: 90 log lines
37
+ // describing zero user actions, repeated on every reload and on every auth
38
+ // check. The MCP status poll adds one more line every nine seconds, forever. Log
39
+ // all of it and the real events — a scan, a login, a 500 — are buried.
40
+ //
41
+ // So: a request is an **action** if it is an API call, and **noise** if it is a
42
+ // static asset. Noise is not thrown away, it is *counted* (see `NoiseCounter`),
43
+ // because "nothing happened" and "90 files were served" are different facts and
44
+ // only one of them is interesting most of the time.
45
+ export function classifyPath(pathname = '') {
46
+ const path = String(pathname).split('?')[0];
47
+ if (path === '/api/health') return 'poll'; // uptime checks
48
+ if (path.startsWith('/api/')) return 'action';
49
+ return 'noise';
50
+ }
51
+
52
+ export function classifyRequest(req = {}) {
53
+ const kind = classifyPath(req.path);
54
+ // A failure is always an action, whatever it was: a 404 on a missing asset is
55
+ // a broken build, and a 500 is a bug. Neither is noise to be summarized away.
56
+ if (Number(req.status) >= 400) return 'action';
57
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') return 'action';
58
+ return kind;
59
+ }
29
60
 
30
61
  // One line, no styling: what a log file holds and what a test asserts on.
31
62
  export function formatMessage(entry) {
@@ -48,16 +79,179 @@ export function formatMessage(entry) {
48
79
  const LEVEL_WIDTH = 5;
49
80
  const GUTTER = ' ';
50
81
 
51
- export function formatEntry(entry) {
52
- const time = String(entry.time || String(entry.ts || '').slice(11, 23));
53
- const level = String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
54
- return `${time}${GUTTER}${level}${GUTTER}${formatMessage(entry)}`;
82
+ // The part of a path that identifies it: the filename with its extension. When
83
+ // a line has to be cut, this is what must survive — `…typescript.js` is useful,
84
+ // `…script` is not. A path with no filename (`/` or `/a/`) falls back to its tail.
85
+ export function identifyingTail(text) {
86
+ const value = String(text);
87
+ const name = value.slice(value.lastIndexOf('/') + 1);
88
+ return name.length >= 4 ? name : value.slice(-8);
89
+ }
90
+
91
+ // How the message area adapts to the terminal. Wide terminals get the whole
92
+ // path; narrow ones get the part that identifies it, elided in the middle — the
93
+ // tail of a path is the informative half, so it survives and the boring prefix
94
+ // is what gets dropped.
95
+ export function fitPath(text, max) {
96
+ const value = String(text);
97
+ if (max <= 0) return '';
98
+ if (value.length <= max) return value;
99
+ if (max === 1) return '…';
100
+ // An ellipsis stands in for the dropped prefix, so do not also add a leading
101
+ // slash: `/…/file.js` reads as a path, `//file.js` reads as a typo. The kept
102
+ // tail is the filename, so a long path loses its directories rather than
103
+ // losing its extension — `…typescript.js` is useful, `…typescript` is not.
104
+ const tail = identifyingTail(value);
105
+ if (tail.length + 1 <= max) return '…' + tail;
106
+ return '…' + value.slice(-(max - 1));
107
+ }
108
+
109
+ // How much room the message gets, and whether the level column is affordable.
110
+ //
111
+ // The fixed prefix is the time, two gutters, and the level column. On a narrow
112
+ // terminal that fixed cost eats the whole line, so two things give way: below 52
113
+ // columns the level column is dropped, and below 24 the timestamp reduces to
114
+ // HH:MM. Below 12 there is no room for any prefix at all, so both are dropped.
115
+ //
116
+ // Crucially the prefix never exceeds the terminal. An earlier version floored
117
+ // `messageWidth` at 12, which meant a 20-column line asked for 20 columns of
118
+ // message, overflowed, and got clamped from the front — producing `pt.js 200`,
119
+ // the tail of a line with its head cut off. The prefix is the part that must
120
+ // yield, not the message.
121
+ export const LEVEL_MIN_COLUMNS = 52;
122
+ export const SHORT_TIME_COLUMNS = 24;
123
+ export const NO_TIME_COLUMNS = 12;
124
+
125
+ export function showLevel(columns) {
126
+ return (columns || 80) >= LEVEL_MIN_COLUMNS;
127
+ }
128
+
129
+ export function showFullTime(columns) {
130
+ return (columns || 80) >= SHORT_TIME_COLUMNS;
131
+ }
132
+
133
+ export function showTime(columns) {
134
+ return (columns || 80) >= NO_TIME_COLUMNS;
135
+ }
136
+
137
+ // The time as it will actually be printed, so callers do not re-derive the same
138
+ // decision and get it subtly wrong.
139
+ export function timeColumn(entry, columns) {
140
+ if (!showTime(columns)) return '';
141
+ const raw = String(entry.time || String(entry.ts || '').slice(11, 19));
142
+ return showFullTime(columns) ? raw : raw.slice(0, 5);
143
+ }
144
+
145
+ // Everything before the message: time + gutter + optional level + gutter.
146
+ //
147
+ // Deliberately computed from the *width* alone, not from an entry: an earlier
148
+ // version asked `timeColumn({}, columns)`, which has no timestamp and so
149
+ // reported a zero-width prefix — and then every line was built 19 columns too
150
+ // long and overflowed. The prefix is a property of the terminal, not of the log
151
+ // line, so it is computed from the terminal.
152
+ export function prefixWidth(columns) {
153
+ if (!showTime(columns)) return 0;
154
+ const time = showFullTime(columns) ? 12 : 5;
155
+ return time + GUTTER.length + (showLevel(columns) ? LEVEL_WIDTH + GUTTER.length : 0);
156
+ }
157
+
158
+ export function messageWidth(columns) {
159
+ const total = columns || 80;
160
+ return Math.max(0, total - prefixWidth(total));
161
+ }
162
+
163
+ export function formatEntry(entry, { columns } = {}) {
164
+ const time = timeColumn(entry, columns);
165
+ const level = showLevel(columns) ? entryLevel(entry) + GUTTER : '';
166
+ const head = time ? time + GUTTER + level : '';
167
+ // The message was already fitted to `messageWidth` and the head is exactly
168
+ // `prefixWidth`, so the line is exactly the terminal width by construction —
169
+ // no trailing clamp, and therefore no risk of cutting a word in half.
170
+ return head + fitMessage(entry, columns);
171
+ }
172
+
173
+ // One line, fitted to the terminal.
174
+ //
175
+ // What gets sacrificed as space runs out, in order: the timing, then the HTTP
176
+ // status, then the level column, then the *method* — and the path's tail is
177
+ // protected, because the filename is the one thing that still identifies the
178
+ // request. A 40-column terminal gets `…zer/languages/typescript.js` (the
179
+ // extension intact); an 80-column one gets the whole sentence.
180
+ function fitMessage(entry, columns) {
181
+ const text = formatMessage(entry);
182
+ const room = messageWidth(columns);
183
+ if (text.length <= room) return text;
184
+ if (!entry.method || !entry.path) return fitPath(text, room);
185
+
186
+ const status = entry.status === undefined ? '' : ` ${entry.status}`;
187
+ const method = `${entry.method} `;
188
+ // Drop the status before the method, and the method before any of the path.
189
+ const withStatus = method + fitPath(entry.path, room - method.length - status.length) + status;
190
+ if (withStatus.length <= room) return withStatus;
191
+ const withoutStatus = method + fitPath(entry.path, room - method.length);
192
+ if (withoutStatus.length <= room) return withoutStatus;
193
+ // Last resort: the path alone, at exactly the room available. `fitPath` is
194
+ // given the exact width, so this is always exactly `room` — never over, which
195
+ // is what would make the caller's clamp cut the *front* of the line and leave
196
+ // an unreadable tail like `pt.js 200`.
197
+ return fitPath(entry.path, room);
198
+ }
199
+
200
+ // A summary of suppressed noise renders as a footnote, not an event: dim, and
201
+ // with no level column, so the eye skips it while scanning for real lines. It
202
+ // occupies the same time gutter so it still lines up in the scrollback.
203
+ export function renderEntry(entry, { color = false, columns } = {}) {
204
+ const message = fitMessage(entry, columns);
205
+ const limit = Math.max(8, columns || 80);
206
+ if (entry.summary) {
207
+ const pad = showLevel(columns) ? ' '.repeat(LEVEL_WIDTH) + GUTTER : '';
208
+ return paint(timeColumn(entry, columns), 'gray', color)
209
+ + GUTTER + paint(GUTTER + pad + message, 'gray', color);
210
+ }
211
+ const painted = paint(timeColumn(entry, columns), 'gray', color) + GUTTER
212
+ + (showLevel(columns) ? paint(entryLevel(entry), LEVEL_COLOR[entry.level] || 'gray', color) + GUTTER : '')
213
+ + message;
214
+ // Clamp on *visible* width: the ANSI codes cost nothing on screen, so slicing
215
+ // the painted string by character count would cut a real character and leave
216
+ // the escape sequence unterminated.
217
+ return visibleWidth(painted) <= limit ? painted : trimVisible(painted, limit);
218
+ }
219
+
220
+ // Visible width, ANSI excluded.
221
+ function visibleWidth(text) {
222
+ return String(text).replace(/\x1b\[[0-9;]*m/g, '').length;
223
+ }
224
+
225
+ // Cut a painted string to `max` visible columns, passing escape sequences through
226
+ // and never splitting one, then resetting so the terminal is left clean.
227
+ function trimVisible(text, max) {
228
+ const pattern = /\x1b\[[0-9;]*m/g;
229
+ let out = '';
230
+ let seen = 0;
231
+ let last = 0;
232
+ let match;
233
+ while ((match = pattern.exec(text)) !== null) {
234
+ const chunk = text.slice(last, match.index);
235
+ if (seen + chunk.length >= max) return out + chunk.slice(0, Math.max(0, max - seen)) + '\x1b[0m';
236
+ out += chunk + match[0];
237
+ seen += chunk.length;
238
+ last = pattern.lastIndex;
239
+ }
240
+ const rest = text.slice(last);
241
+ return out + (seen + rest.length > max ? rest.slice(0, max - seen) : rest);
55
242
  }
56
243
 
57
- export function renderEntry(entry, { color = false } = {}) {
58
- const time = String(entry.time || String(entry.ts || '').slice(11, 19));
59
- const level = String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
60
- return paint(time, 'gray', color) + GUTTER + paint(level, LEVEL_COLOR[entry.level] || 'gray', color) + GUTTER + formatMessage(entry);
244
+ function entryLevel(entry) {
245
+ return String(entry.level || 'info').toUpperCase().padEnd(LEVEL_WIDTH);
246
+ }
247
+
248
+ // A human summary of suppressed noise, so silence is never ambiguous. "Nothing
249
+ // happened" and "94 files were served" are different facts; printing the second
250
+ // as one line every so often keeps the first honest.
251
+ export function noiseSummary(count, ms = 0) {
252
+ const files = `${count} static file${count === 1 ? '' : 's'}`;
253
+ const took = ms >= 1000 ? ` in ${(ms / 1000).toFixed(1)}s` : ms ? ` in ${ms}ms` : '';
254
+ return `served ${files}${took}`;
61
255
  }
62
256
 
63
257
  export function logEntry(level, msg, extra = {}) {
@@ -86,13 +280,38 @@ export function createLogger(level = process.env.LOG_LEVEL || 'info', options =
86
280
  const out = options.stdout || process.stdout;
87
281
  const err = options.stderr || process.stderr;
88
282
 
283
+ // The noise policy. `info` (the default) drops successful static assets and
284
+ // uptime polls; `debug` shows every request, which is what you want when you
285
+ // are debugging the server rather than watching it. ONBOARDER_LOG_VERBOSE=1 is
286
+ // the same escape hatch without changing the level.
287
+ const quiet = options.quiet ?? (minLevel > levels.debug && process.env.ONBOARDER_LOG_VERBOSE !== '1');
288
+ // Read the terminal width per write, not once at construction: a log line
289
+ // printed after the user resized their window should use the new width.
290
+ const columns = options.columns ?? (() => out.columns || undefined);
291
+
292
+ // Suppressed-asset accounting. Assets are counted, not printed, and the tally
293
+ // is flushed as ONE dim summary line once it reaches `flushAt` — so a page
294
+ // load that serves 94 files produces one line, and a terminal that is idle
295
+ // still admits the server is busy rather than looking dead.
296
+ const flushAt = options.flushAt ?? 40;
297
+ let suppressed = 0;
298
+ let since = Date.now();
299
+
300
+ function flush(force = false) {
301
+ if (!suppressed) return;
302
+ if (!force && suppressed < flushAt) return;
303
+ write(logEntry('info', noiseSummary(suppressed, Date.now() - since), { summary: true }));
304
+ suppressed = 0;
305
+ since = Date.now();
306
+ }
307
+
89
308
  function write(entry) {
90
309
  if (format === 'json') {
91
310
  const line = JSON.stringify(entry) + '\n';
92
311
  (entry.level === 'warn' || entry.level === 'error' ? err : out).write(line);
93
312
  return;
94
313
  }
95
- (entry.level === 'warn' || entry.level === 'error' ? err : out).write(renderEntry(entry, { color }) + '\n');
314
+ (entry.level === 'warn' || entry.level === 'error' ? err : out).write(renderEntry(entry, { color, columns: columns() }) + '\n');
96
315
  }
97
316
 
98
317
  function log(lvl, msg, extra = {}) {
@@ -105,6 +324,20 @@ export function createLogger(level = process.env.LOG_LEVEL || 'info', options =
105
324
  info: (msg, extra) => log('info', msg, extra),
106
325
  warn: (msg, extra) => log('warn', msg, extra),
107
326
  error: (msg, extra) => log('error', msg, extra),
108
- http: (req) => log(req.status >= 500 ? 'error' : 'info', 'http', req),
327
+ // The one place the noise policy lives. A successful GET of a static file is
328
+ // counted, not printed; a scan, a login, or any failure is printed.
329
+ http: (req) => {
330
+ if (quiet && classifyRequest(req) === 'noise') {
331
+ suppressed += 1;
332
+ flush();
333
+ return;
334
+ }
335
+ if (quiet && classifyPath(req.path) === 'poll') return;
336
+ flush();
337
+ log(Number(req.status) >= 500 ? 'error' : 'info', 'http', req);
338
+ },
339
+ // Let a shutdown path (or a test) get the tally out before the process dies.
340
+ flush: () => flush(true),
341
+ suppressed: () => suppressed,
109
342
  };
110
343
  }
@@ -52,9 +52,14 @@ export function computeFacts(scan, manifest = {}) {
52
52
  .map((f) => f.path)
53
53
  .sort();
54
54
 
55
+ // A hub is a file other files depend on. The threshold is 2, not 3: in a
56
+ // repo with 20 files, "imported by two others" *is* the most-depended-upon
57
+ // file, and a threshold of 3 reported no hubs at all for a whole small project
58
+ // — the map of a small repo is exactly where a hub is most useful. Two is the
59
+ // lowest value that still means "more than one other file reaches for this".
55
60
  const hubs = files
56
61
  .map((f) => ({ path: f.path, fanIn: fanIn.get(f.path) || 0, fanOut: fanOut.get(f.path) || 0 }))
57
- .filter((h) => h.fanIn >= 3)
62
+ .filter((h) => h.fanIn >= 2)
58
63
  .sort((a, b) => b.fanIn - a.fanIn);
59
64
 
60
65
  const orphans = files
@@ -1,19 +1,40 @@
1
1
  // C# analyzer
2
- import { blankComments, lineCounter, uniqueBy } from '../util.js';
2
+ //
3
+ // C# is namespace-rooted exactly as Java is package-rooted: a file's path is its
4
+ // namespace spelled with slashes, under a project root. The difference that
5
+ // matters is the import. Java's `import a.b.C;` names a *class*, so one path
6
+ // answers it; C#'s `using A.B;` names a *namespace*, and the classes inside it
7
+ // are not knowable from the import string. So a namespace import resolves to a
8
+ // directory and the scanner links to every file in it — the same shape Go uses
9
+ // for packages.
10
+ import { blankComments, lineCounter } from '../util.js';
3
11
 
4
12
  export const extensions = ['.cs'];
5
13
 
14
+ const NAMESPACE_RE = /^\s*namespace\s+([A-Za-z0-9_.]+)\s*[;{]/m;
15
+
6
16
  export function analyze(source, path) {
7
17
  const clean = blankComments(source);
8
18
  const lineAt = lineCounter(clean);
9
19
 
20
+ // The file's own namespace. Captured first because every import is resolved
21
+ // relative to it — this declaration is the only place the root is written down.
22
+ // Block-scoped namespaces (`namespace A.B { }`) are the norm, and the regex
23
+ // accepts both the `;` and `{` forms.
24
+ const namespace = (clean.match(NAMESPACE_RE) || [])[1] || '';
25
+
10
26
  const imports = [];
11
- for (const m of clean.matchAll(/\busing\s+([a-zA-Z0-9_.]+)\s*;/g)) {
12
- imports.push({ spec: m[1], line: lineAt(m.index) });
27
+ // `using static A.B.C;` names a type (and optionally a member of it); plain
28
+ // `using A.B;` names a namespace. The distinction decides whether we look for
29
+ // a file or a directory.
30
+ for (const m of clean.matchAll(/\busing\s+(static\s+)?([A-Za-z0-9_.]+)\s*;/g)) {
31
+ imports.push({ spec: m[2], static: !!m[1], namespace, line: lineAt(m.index) });
13
32
  }
33
+ // `using X = Y;` aliases and global usings are out of scope; a spec that fails
34
+ // to resolve is reported as unresolved rather than guessed at.
14
35
 
15
36
  const classes = [];
16
- for (const m of clean.matchAll(/(\[[^\]]+\]\s*)*\b(?:public\s+|private\s+|protected\s+|internal\s+)*(?:abstract\s+|sealed\s+)?(?:class|interface|struct|record|enum)\s+([a-zA-Z0-9_]+)/g)) {
37
+ for (const m of clean.matchAll(/(\[[^\]]+\]\s*)*\b(?:public\s+|private\s+|protected\s+|internal\s+)*(?:abstract\s+|sealed\s+|static\s+|partial\s+)*(?:class|interface|struct|record|enum)\s+([a-zA-Z0-9_]+)/g)) {
17
38
  const attributes = [];
18
39
  if (m[1]) {
19
40
  for (const am of m[1].matchAll(/\[([A-Za-z0-9_]+)/g)) {
@@ -30,10 +51,64 @@ export function analyze(source, path) {
30
51
  exports: classes.map(c => ({ name: c.name, kind: 'class' })),
31
52
  functions: [],
32
53
  classes,
33
- hasMain
54
+ hasMain,
55
+ namespace,
34
56
  };
35
57
  }
36
58
 
37
- export function resolveImport(spec, fromPath, has, context = {}) {
59
+ // A C# `using` is resolved by *path suffix*, not by a root prefix. See
60
+ // `resolveImport` below for why that distinction matters — it is the whole
61
+ // difference between C# and Java, and getting it backwards drops every import.
62
+ //
63
+ // `sourceRootFor` is kept and exported because it is the answer for the layouts
64
+ // where a project *does* mirror the full namespace (`Acme/Services/User.cs`).
65
+ // It is a fallback, not the main path.
66
+ export function sourceRootFor(fromPath, namespace) {
67
+ if (!namespace) return null;
68
+ const suffix = namespace.replace(/\./g, '/');
69
+ const normalized = String(fromPath).replace(/\\/g, '/');
70
+ const nested = normalized.lastIndexOf('/' + suffix + '/');
71
+ if (nested >= 0) return normalized.slice(0, nested + 1);
72
+ if (normalized === suffix || normalized.startsWith(suffix + '/')) return '';
73
+ return null;
74
+ }
75
+
76
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
77
+ const parts = spec.split('.').filter(Boolean);
78
+ const findDir = (segments) => (segments.length ? context.findDirEndingWith?.(segments.join('/')) : null);
79
+ if (!parts.length) return { unresolved: spec };
80
+
81
+ // A C# `using` is ambiguous from the string alone: in `using Acme.Models;`,
82
+ // `Models` might be a namespace (link its directory) or a type (link the
83
+ // file). Both readings are tried, most specific first.
84
+ //
85
+ // The other half of the puzzle is that C# resolves by *path suffix*, not by a
86
+ // root prefix the way Java does. `namespace Acme.Services` conventionally
87
+ // lives in `src/Services/`: the folders mirror the namespace, but the leading
88
+ // `Acme` — the project or company root — is not a directory. So the namespace
89
+ // segments are matched against the *tail* of a real directory path, which is
90
+ // what `findDirEndingWith` answers. Stripping the whole namespace instead, the
91
+ // Java approach, matches nothing here and silently drops every import.
92
+
93
+ // Reading 1: the last segment is a type. Find the directory holding its
94
+ // namespace, then the file named after it.
95
+ const namespaceParts = parts.slice(0, -1);
96
+ for (let take = namespaceParts.length; take > 0; take -= 1) {
97
+ const dir = findDir(namespaceParts.slice(namespaceParts.length - take));
98
+ if (!dir) continue;
99
+ const exact = dir + '/' + parts[parts.length - 1] + '.cs';
100
+ if (has(exact)) return { path: exact };
101
+ if (meta.static) return { unresolved: spec }; // `using static A.B.C;` is always a type
102
+ }
103
+
104
+ // Reading 2: the whole spec is a namespace, so link its directory.
105
+ for (let take = parts.length; take > 0; take -= 1) {
106
+ const dir = findDir(parts.slice(parts.length - take));
107
+ if (dir) return { packageDir: dir };
108
+ }
109
+
110
+ // The BCL (`System`, `System.Collections.Generic`) and every NuGet package land
111
+ // here. Staying unresolved is the honest answer: a file we invent would be a
112
+ // node in the graph that does not exist.
38
113
  return { unresolved: spec };
39
114
  }
@@ -1,15 +1,33 @@
1
1
  // Java analyzer
2
+ //
3
+ // Java is package-rooted: a source file's path *is* its package spelled with
4
+ // slashes, under a source root (`src/main/java` in Maven, `src/main/java` in
5
+ // Gradle, or a plain directory in a one-off project). That single fact is what
6
+ // turns an import into a file, and it is why Java can have a real import graph
7
+ // at all — the C# analyzer sitting next to this one cannot, because a C# file
8
+ // carries no path↔namespace correspondence to invert.
2
9
  import { blankComments, lineCounter, uniqueBy } from '../util.js';
3
10
 
4
11
  export const extensions = ['.java'];
5
12
 
13
+ const PACKAGE_RE = /^[ \t]*package\s+([a-zA-Z0-9_.]+)\s*;/m;
14
+
6
15
  export function analyze(source, path) {
7
16
  const clean = blankComments(source);
8
17
  const lineAt = lineCounter(clean);
9
18
 
19
+ // The file's own package, captured first because every import in the file is
20
+ // resolved relative to it: `resolveImport` has to know where the source root
21
+ // ends and the package begins, and this declaration is the only place that is
22
+ // written down. A file with no `package` is in the default package, which is a
23
+ // real (if unfashionable) case rather than an error.
24
+ const packageName = (clean.match(PACKAGE_RE) || [])[1] || '';
25
+
10
26
  const imports = [];
11
27
  for (const m of clean.matchAll(/\bimport\s+(static\s+)?([a-zA-Z0-9_.]+)/g)) {
12
- imports.push({ spec: m[2], static: !!m[1], line: lineAt(m.index) });
28
+ // `packageName` rides along on the import so the resolver can do its job
29
+ // from the import object alone — which is the fifth argument scan.js passes.
30
+ imports.push({ spec: m[2], static: !!m[1], packageName, line: lineAt(m.index) });
13
31
  }
14
32
 
15
33
  const classes = [];
@@ -26,14 +44,71 @@ export function analyze(source, path) {
26
44
  const hasMain = /public\s+static\s+void\s+main\s*\(/.test(clean);
27
45
 
28
46
  return {
29
- imports,
47
+ imports: uniqueBy(imports, (i) => i.spec + (i.static ? ' static' : '')),
30
48
  exports: classes.map(c => ({ name: c.name, kind: 'class' })),
31
49
  functions: [],
32
50
  classes,
33
- hasMain
51
+ hasMain,
52
+ packageName,
34
53
  };
35
54
  }
36
55
 
37
- export function resolveImport(spec, fromPath, has, context = {}) {
56
+ // The source root: the part of this file's path that sits *above* its package.
57
+ // `src/main/java/com/example/app/AppController.java` with package
58
+ // `com.example.app` gives `src/main/java/` — and that prefix is exactly what
59
+ // turns `com.example.model.User` into a path worth looking for.
60
+ //
61
+ // Returns '' when the file declares no package: with nothing to strip there is
62
+ // no way to know where the root is, and guessing would mean inventing files.
63
+ export function sourceRootFor(fromPath, packageName) {
64
+ if (!packageName) return null;
65
+ const suffix = packageName.replace(/\./g, '/');
66
+ const normalized = String(fromPath).replace(/\\/g, '/');
67
+ // Two shapes, and both are real. With a source root the package segment is
68
+ // preceded by a directory (`src/main/java/com/acme/…`); in a project with no
69
+ // root at all the path *begins* with the package (`com/acme/…`) and the root
70
+ // is the empty string. That is not the same as "no root" — it is the repo top,
71
+ // which is a perfectly good answer.
72
+ const nested = normalized.lastIndexOf('/' + suffix + '/');
73
+ if (nested >= 0) return normalized.slice(0, nested + 1);
74
+ if (normalized === suffix || normalized.startsWith(suffix + '/')) return '';
75
+ // The package is not in the path at all (a generated source dir, a file moved
76
+ // out of its package). No root can be inferred, and inventing one would put a
77
+ // node in the graph that does not exist.
78
+ return null;
79
+ }
80
+
81
+ // Candidate file paths for an import, longest spec first.
82
+ //
83
+ // The longest is the obvious one (`model.User` → `…/model/User.java`). The
84
+ // shorter ones exist because Java lets you import a *nested* type
85
+ // (`com.example.Outer.Inner` lives in `Outer.java`) and a *static member*
86
+ // (`com.example.Constants.MAX` lives in `Constants.java`). Rather than
87
+ // special-case the syntax, walk back a segment at a time and take the first
88
+ // that exists. The static flag only decides where the walk starts, so the
89
+ // common case does not pay for the rare one.
90
+ function candidatesFor(spec, root, { static: isStatic = false } = {}) {
91
+ const parts = spec.split('.').filter(Boolean);
92
+ const out = [];
93
+ const start = isStatic && parts.length > 1 ? 1 : 0;
94
+ for (let end = parts.length; end > start; end -= 1) {
95
+ out.push(root + parts.slice(0, end).join('/') + '.java');
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
101
+ const root = sourceRootFor(fromPath, meta.packageName);
102
+ // `null` means "no root could be inferred". `''` means the root is the repo
103
+ // top, which is the correct answer for a project laid out with no source root
104
+ // at all — treating that as "no root" silently dropped every flat-layout import.
105
+ if (root === null) return { unresolved: spec };
106
+ for (const candidate of candidatesFor(spec, root, { static: meta.static })) {
107
+ if (has(candidate)) return { path: candidate };
108
+ }
109
+ // Nothing in the repo matched. The JDK and every third-party library land
110
+ // here, and staying `unresolved` is the honest answer: inventing a file would
111
+ // put a node in the graph that does not exist. scan.js reports these by name
112
+ // so the reader knows exactly what the analyzer could not place.
38
113
  return { unresolved: spec };
39
114
  }
@@ -1,4 +1,18 @@
1
1
  // Rust analyzer
2
+ //
3
+ // Rust has no `import` in the Java sense: a crate is a module tree, and `use`
4
+ // names a path *into* that tree. The mapping is still real, though, and it is
5
+ // what gives a Rust repo a graph:
6
+ //
7
+ // crate root src/lib.rs, src/main.rs, or crates/<name>/src/lib.rs
8
+ // module `a` src/a.rs or src/a/mod.rs
9
+ // `crate::a::T` T is declared in src/a.rs (or src/a/mod.rs)
10
+ //
11
+ // So a `use` resolves to the *module file* that owns the item, and the leading
12
+ // `crate` is just the crate root. `self::` and `super::` are relative to the
13
+ // current file's module directory. Anything outside the crate — `std`, a
14
+ // dependency from Cargo.toml — stays unresolved, because inventing a file would
15
+ // be a node in the graph that does not exist.
2
16
  import { blankComments, lineCounter, uniqueBy } from '../util.js';
3
17
 
4
18
  export const extensions = ['.rs'];
@@ -8,7 +22,9 @@ export function analyze(source, path) {
8
22
  const lineAt = lineCounter(clean);
9
23
 
10
24
  const imports = [];
11
- for (const m of clean.matchAll(/\b(?:use|mod)\s+([a-zA-Z0-9_:]+)/g)) {
25
+ // `use foo::bar;` and `mod foo;` are both dependencies on a file, and both are
26
+ // worth an edge. `pub use` is a re-export — still a real dependency.
27
+ for (const m of clean.matchAll(/\b(?:pub\s+)?(?:use|mod)\s+([a-zA-Z0-9_:]+)/g)) {
12
28
  imports.push({ spec: m[1], line: lineAt(m.index) });
13
29
  }
14
30
 
@@ -37,6 +53,92 @@ export function analyze(source, path) {
37
53
  };
38
54
  }
39
55
 
40
- export function resolveImport(spec, fromPath, has, context = {}) {
56
+ const dirOfPath = (p) => {
57
+ const at = String(p).lastIndexOf('/');
58
+ return at < 0 ? '' : String(p).slice(0, at + 1);
59
+ };
60
+
61
+ // The crate root: the `src/` directory, wherever it sits. `src/lib.rs` → `src/`;
62
+ // `crates/foo/src/main.rs` → `crates/foo/src/`. A crate with no `src/` (a flat
63
+ // `lib.rs` in the repo root) is rooted at that file's own directory, which is the
64
+ // same rule with one fewer component to find.
65
+ export function crateRootFor(fromPath) {
66
+ const normalized = String(fromPath).replace(/\\/g, '/');
67
+ const segments = normalized.split('/');
68
+ const at = segments.lastIndexOf('src');
69
+ if (at < 0) return dirOfPath(normalized);
70
+ return segments.slice(0, at + 1).join('/') + '/';
71
+ }
72
+
73
+ // The directory that holds the *children* of the module this file defines.
74
+ //
75
+ // This is not simply the dirname, and getting it wrong breaks every `self::`.
76
+ // src/lib.rs → `src/` the crate root's children sit in src/
77
+ // src/a/b.rs → `src/a/b/` the module is `a::b`, so `self::x` is a/b/x.rs
78
+ // src/a/b/mod.rs → `src/a/b/` the same module, declared the directory way
79
+ //
80
+ // The lib/main special case is the one that is easy to miss: they are named
81
+ // after the crate, not after their module, so stripping `.rs` from `src/lib.rs`
82
+ // would invent a module called `lib`.
83
+ export function moduleDirFor(fromPath) {
84
+ const normalized = String(fromPath).replace(/\\/g, '/');
85
+ const dir = dirOfPath(normalized);
86
+ const name = normalized.slice(dir.length);
87
+ if (name === 'mod.rs' || name === 'lib.rs' || name === 'main.rs') return dir;
88
+ return normalized.replace(/\.rs$/, '') + '/';
89
+ }
90
+
91
+ // A module named by a path is either `path.rs` or `path/mod.rs`. Both are
92
+ // idiomatic Rust, so both are tried and the first that exists wins.
93
+ function moduleCandidates(path) {
94
+ return [path + '.rs', path + '/mod.rs'];
95
+ }
96
+
97
+ export function resolveImport(spec, fromPath, has, context = {}, meta = {}) {
98
+ const from = String(fromPath).replace(/\\/g, '/');
99
+ const raw = String(spec);
100
+ // `::foo::Bar` is a 2018 absolute path — another crate, not this one.
101
+ if (raw.startsWith('::')) return { unresolved: spec };
102
+ const segments = raw.split('::').filter(Boolean);
103
+ if (!segments.length) return { unresolved: spec };
104
+
105
+ // Where the path starts. `crate::` is the crate root; `self::` and a bare
106
+ // `mod foo;` are the current file's own module; `super::` is one level up.
107
+ const head = segments[0];
108
+ let base;
109
+ let rest;
110
+ if (head === 'crate') {
111
+ base = crateRootFor(from);
112
+ rest = segments.slice(1);
113
+ } else if (head === 'super') {
114
+ // `dirOfPath` already ends in a separator, so do not add another — `src/a//`
115
+ // never matches a real path, and the miss would be silent.
116
+ base = dirOfPath(moduleDirFor(from).replace(/\/$/, ''));
117
+ rest = segments.slice(1);
118
+ } else if (head === 'self') {
119
+ base = moduleDirFor(from);
120
+ rest = segments.slice(1);
121
+ } else {
122
+ // A bare `mod foo;` declares a module beside the current one. A bare
123
+ // `use foo::Bar;` means an *external crate* in Rust 2018, so it will not be
124
+ // found here and stays unresolved — which is the right answer for
125
+ // `serde::Serialize` and every other dependency.
126
+ base = moduleDirFor(from);
127
+ rest = segments;
128
+ }
129
+ if (!rest.length) return { unresolved: spec };
130
+
131
+ // `crate::a::b::T` means T is declared *in* the module `a::b`, so the answer is
132
+ // the module file for the longest prefix that exists. Trying the deepest path
133
+ // first costs one lookup and covers the flat `mod a;` → `a.rs` case.
134
+ for (let end = rest.length; end > 0; end -= 1) {
135
+ const candidate = base + rest.slice(0, end).join('/');
136
+ for (const file of moduleCandidates(candidate)) {
137
+ if (has(file)) return { path: file };
138
+ }
139
+ }
140
+
141
+ // `std`, `serde`, anything from Cargo.toml: outside this crate. Unresolved is
142
+ // the honest answer, and scan.js reports these by name.
41
143
  return { unresolved: spec };
42
144
  }
@@ -126,8 +126,31 @@ export async function scanRepo(source, options = {}) {
126
126
  }
127
127
  const findByName = (n) => nameIndex.get(n) || null;
128
128
 
129
+ // Find a directory whose path *ends with* a run of segments. This is what C#
130
+ // needs and Java does not: a C# file's folder mirrors the namespace, but the
131
+ // root namespace (the company or project name) is normally not a directory,
132
+ // so `namespace Acme.Services` lives in `src/Services/`. Matching on a path
133
+ // suffix is the only thing that finds it — stripping the whole namespace, the
134
+ // Java approach, matches nothing and silently drops every import.
135
+ //
136
+ // Keyed by the last three segments, which is enough to tell
137
+ // `Acme/Models/Entities` apart from a bare `Entities` elsewhere while staying
138
+ // O(1) per lookup. A collision at the shortest key keeps the first match, and
139
+ // callers query longest-first, so the most specific answer is tried first.
140
+ const dirByTail = new Map();
141
+ for (const d of dirSet) {
142
+ if (!d) continue;
143
+ const segments = d.split('/');
144
+ for (let take = 1; take <= 3 && take <= segments.length; take += 1) {
145
+ const key = segments.slice(segments.length - take).join('/');
146
+ if (!dirByTail.has(key)) dirByTail.set(key, d);
147
+ }
148
+ }
149
+ const findDirEndingWith = (suffix) => (suffix ? dirByTail.get(suffix) || null : null);
150
+
129
151
  const context = {
130
152
  hasDir,
153
+ findDirEndingWith,
131
154
  findByName,
132
155
  modulePath: await readModulePath(source),
133
156
  tsPaths: await readTsConfigPaths(source),
@@ -212,15 +235,20 @@ export async function scanRepo(source, options = {}) {
212
235
  const tally = { total: 0, internal: 0, external: 0, unresolved: 0 };
213
236
  const unresolvedSpecs = new Map(); // spec -> { count, from }
214
237
 
215
- // Go resolves an import to a *directory*, and every .go file in it is a
238
+ // A resolver may answer with a *directory* instead of a file: Go's import is a
239
+ // package, and a C# `using Acme.Models;` names a namespace whose classes we
240
+ // cannot know from the import alone. Every file in that directory is a
216
241
  // target. Indexed once here rather than scanned per import — a Go repo with
217
242
  // 1,000 files and 8,000 imports was doing eight million comparisons for it.
218
- const goFilesByDir = new Map();
243
+ //
244
+ // Language-neutral on purpose: this used to be `goFilesByDir`, built only from
245
+ // Go files, which meant no other language could use the mechanism that already
246
+ // existed for exactly this problem.
247
+ const filesByDir = new Map();
219
248
  for (const f of files) {
220
- if (f.lang !== 'go') continue;
221
- const bucket = goFilesByDir.get(f.dir);
249
+ const bucket = filesByDir.get(f.dir);
222
250
  if (bucket) bucket.push(f.path);
223
- else goFilesByDir.set(f.dir, [f.path]);
251
+ else filesByDir.set(f.dir, [f.path]);
224
252
  }
225
253
 
226
254
  for (const file of files) {
@@ -233,7 +261,10 @@ export async function scanRepo(source, options = {}) {
233
261
  addEdge(edges, edgeKeys, file.path, res.path, 'imports', imp.symbols);
234
262
  } else if (res.packageDir) {
235
263
  tally.internal++;
236
- for (const target of goFilesByDir.get(res.packageDir) || []) {
264
+ // A directory import means "everything in there". addEdge de-duplicates,
265
+ // so a package with fifty files yields fifty honest edges rather than
266
+ // one edge standing in for all of them.
267
+ for (const target of filesByDir.get(res.packageDir) || []) {
237
268
  addEdge(edges, edgeKeys, file.path, target, 'imports', imp.symbols);
238
269
  }
239
270
  } else if (res.external) {