codebase-onboarder 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -6
- package/cli/commands.js +31 -0
- package/cli/explorer/app.js +295 -0
- package/cli/explorer/commands.js +197 -0
- package/cli/explorer/session.js +161 -0
- package/cli/explorer/views.js +549 -0
- package/cli/main.js +36 -8
- package/cli/ui.js +13 -29
- package/package.json +1 -1
- package/server/index.js +32 -28
- package/server/layout.js +73 -0
- package/server/logger.js +244 -11
- package/shared/analyzer/graph.js +6 -1
- package/shared/analyzer/languages/csharp.js +81 -6
- package/shared/analyzer/languages/java.js +79 -4
- package/shared/analyzer/languages/rust.js +104 -2
- package/shared/analyzer/scan.js +37 -6
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
83
|
-
export const hint = (text) => ({ hint: text });
|
|
62
|
+
export { width, fit, termWidth, hint, row };
|
|
84
63
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.0",
|
|
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
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
89
|
-
|
|
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
|
-
|
|
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)
|
|
101
|
-
|
|
102
|
-
|
|
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
|
package/server/layout.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
}
|
package/shared/analyzer/graph.js
CHANGED
|
@@ -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 >=
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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+)
|
|
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
|
-
|
|
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
|
}
|