dslinter 0.0.34 → 0.0.36

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.0.36
4
+
5
+ [compare changes](https://github.com/jrmybtlr/DSLinter/compare/v0.0.35...v0.0.36)
6
+
7
+ ### 🏡 Chore
8
+
9
+ - Update native binding version checks to 0.0.34 in dashboard package ([917860a](https://github.com/jrmybtlr/DSLinter/commit/917860a))
10
+
11
+ ### ❤️ Contributors
12
+
13
+ - Jeremy Butler <jeremy.butler@laravel.com>
14
+
15
+ ## v0.0.35
16
+
17
+ [compare changes](https://github.com/jrmybtlr/DSLinter/compare/v0.0.34...v0.0.35)
18
+
19
+ ### 🏡 Chore
20
+
21
+ - Update dependencies and improve dashboard functionality ([71a42f2](https://github.com/jrmybtlr/DSLinter/commit/71a42f2))
22
+ - Update @napi-rs/cli to version 3.6.2 and refactor ANSI regex in dev-banner ([dd264b2](https://github.com/jrmybtlr/DSLinter/commit/dd264b2))
23
+
24
+ ### ❤️ Contributors
25
+
26
+ - Jeremy Butler <jeremy.butler@laravel.com>
27
+
3
28
  ## v0.0.34
4
29
 
5
30
  [compare changes](https://github.com/jrmybtlr/DSLinter/compare/v0.0.33...v0.0.34)
@@ -0,0 +1,264 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+
4
+ const BOX = {
5
+ tl: "╭",
6
+ tr: "╮",
7
+ bl: "╰",
8
+ br: "╯",
9
+ h: "─",
10
+ v: "│",
11
+ };
12
+
13
+ /** Block-letter DSLinter (two lines). */
14
+ export const LOGO = [
15
+ "█▀▄\u2003█▀\u2003█░░\u2003█\u2003█▄░█\u2003▀█▀\u2003█▀▀\u2003█▀█",
16
+ "█▄▀\u2003▄█\u2003█▄▄\u2003█\u2003█░▀█\u2003░█░\u2003██▄\u2003█▀▄",
17
+ ];
18
+
19
+ const ESC = "\u001b";
20
+ const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
21
+
22
+ /** @param {string} s */
23
+ export function stripAnsi(s) {
24
+ return s.replace(ANSI_RE, "");
25
+ }
26
+
27
+ /** @param {string} s */
28
+ export function visibleLength(s) {
29
+ return stripAnsi(s).length;
30
+ }
31
+
32
+ /**
33
+ * @param {string} s
34
+ * @param {number} width
35
+ */
36
+ function padVisible(s, width) {
37
+ const pad = Math.max(0, width - visibleLength(s));
38
+ return s + " ".repeat(pad);
39
+ }
40
+
41
+ /**
42
+ * @param {string} text
43
+ * @param {number} max
44
+ */
45
+ function truncatePlain(text, max) {
46
+ if (text.length <= max) return text;
47
+ if (max <= 1) return text.slice(0, max);
48
+ return `${text.slice(0, max - 1)}…`;
49
+ }
50
+
51
+ /** @param {boolean} enabled */
52
+ function createStyles(enabled) {
53
+ if (!enabled) {
54
+ const id = (s) => s;
55
+ return {
56
+ label: id,
57
+ value: id,
58
+ url: id,
59
+ dim: id,
60
+ ok: id,
61
+ warn: id,
62
+ err: id,
63
+ };
64
+ }
65
+ const esc = (n, s) => `\u001b[${n}m${s}\u001b[0m`;
66
+ return {
67
+ label: (s) => esc("2", s),
68
+ value: (s) => esc("0", s),
69
+ url: (s) => esc("4;36", s),
70
+ dim: (s) => esc("2", s),
71
+ ok: (s) => esc("32", s),
72
+ warn: (s) => esc("33", s),
73
+ err: (s) => esc("31", s),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * @param {string} path
79
+ * @param {number} [maxLen]
80
+ */
81
+ export function shortenPath(path, maxLen = 72) {
82
+ const home = homedir();
83
+ let s = path.startsWith(home) ? `~${path.slice(home.length)}` : path;
84
+ if (s.length <= maxLen) return s;
85
+ const head = Math.ceil((maxLen - 1) / 2);
86
+ const tail = Math.floor((maxLen - 1) / 2);
87
+ return `${s.slice(0, head)}…${s.slice(-tail)}`;
88
+ }
89
+
90
+ /**
91
+ * @param {string} label
92
+ * @param {string} plainValue
93
+ * @param {number} contentWidth
94
+ * @param {(value: string) => string} [styleValue]
95
+ */
96
+ function row(label, plainValue, contentWidth, styleValue = (v) => v) {
97
+ const labelCol = 14;
98
+ const gap = 2;
99
+ const valueWidth = Math.max(1, contentWidth - labelCol - gap);
100
+ const valueLines = wrapPlain(plainValue, valueWidth);
101
+ const lines = [];
102
+ for (let i = 0; i < valueLines.length; i++) {
103
+ const lbl = i === 0 ? padVisible(label, labelCol) : " ".repeat(labelCol);
104
+ lines.push(`${lbl}${" ".repeat(gap)}${styleValue(valueLines[i])}`);
105
+ }
106
+ return lines;
107
+ }
108
+
109
+ /**
110
+ * @param {string} text
111
+ * @param {number} width
112
+ * @returns {string[]}
113
+ */
114
+ function wrapPlain(text, width) {
115
+ if (text.length <= width) return [text];
116
+ const words = text.split(/(\s+)/);
117
+ const lines = [];
118
+ let line = "";
119
+ for (const w of words) {
120
+ if (line.length + w.length <= width) {
121
+ line += w;
122
+ } else if (w.trim() === "") {
123
+ line += w;
124
+ } else if (w.length > width) {
125
+ if (line) lines.push(line.trimEnd());
126
+ for (let i = 0; i < w.length; i += width) {
127
+ lines.push(w.slice(i, i + width));
128
+ }
129
+ line = "";
130
+ } else {
131
+ if (line) lines.push(line.trimEnd());
132
+ line = w.trimStart();
133
+ }
134
+ }
135
+ if (line) lines.push(line.trimEnd());
136
+ return lines.length ? lines : [""];
137
+ }
138
+
139
+ /**
140
+ * @param {string[]} lines
141
+ * @param {number} totalWidth
142
+ */
143
+ function boxLines(lines, totalWidth) {
144
+ const contentWidth = totalWidth - 4;
145
+ const out = [];
146
+ out.push(`${BOX.tl}${BOX.h.repeat(totalWidth - 2)}${BOX.tr}`);
147
+ for (const line of lines) {
148
+ const plain = stripAnsi(line);
149
+ const clipped =
150
+ plain.length > contentWidth ? truncatePlain(plain, contentWidth) : line;
151
+ out.push(`${BOX.v} ${padVisible(clipped, contentWidth)} ${BOX.v}`);
152
+ }
153
+ out.push(`${BOX.bl}${BOX.h.repeat(totalWidth - 2)}${BOX.br}`);
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * @param {{
159
+ * scanPath: string;
160
+ * reportPath: string;
161
+ * apiPort: number;
162
+ * apiAvailable: boolean;
163
+ * dashboardUrl?: string | null;
164
+ * bundledUrl?: string | null;
165
+ * pollMs?: number;
166
+ * }} opts
167
+ * @returns {string}
168
+ */
169
+ export function formatDevBanner(opts) {
170
+ const color = createStyles(process.stderr.isTTY === true);
171
+ const terminalCols = process.stderr.columns ?? 80;
172
+ const maxBox = Math.min(Math.max(terminalCols, 64), 96);
173
+
174
+ const scanAbs = resolve(opts.scanPath);
175
+ const reportAbs = resolve(opts.reportPath);
176
+ const apiBase = `http://127.0.0.1:${opts.apiPort}`;
177
+
178
+ const apiStatusPlain = opts.apiAvailable ? "listening" : "unavailable — port in use";
179
+ const bundledStatusPlain = opts.apiAvailable ? "ready" : "port busy";
180
+ const scanPlain = shortenPath(scanAbs, 80);
181
+ const reportPlain = shortenPath(reportAbs, 80);
182
+
183
+ /** @type {number[]} */
184
+ const plainWidths = [
185
+ ...LOGO.map((l) => visibleLength(l)),
186
+ 14 + 2 + scanPlain.length,
187
+ 14 + 2 + reportPlain.length,
188
+ ];
189
+ if (opts.dashboardUrl) plainWidths.push(14 + 2 + opts.dashboardUrl.length);
190
+ if (opts.bundledUrl) {
191
+ plainWidths.push(14 + 2 + `${opts.bundledUrl} (${bundledStatusPlain})`.length);
192
+ }
193
+ plainWidths.push(14 + 2 + `${apiBase} (${apiStatusPlain})`.length);
194
+ if (opts.apiAvailable) {
195
+ plainWidths.push(14 + 2 + `${apiBase}/dslint-report.json`.length);
196
+ plainWidths.push(14 + 2 + `${apiBase}/events`.length);
197
+ }
198
+ if (opts.pollMs) plainWidths.push(14 + 2 + `polling every ${opts.pollMs} ms`.length);
199
+ plainWidths.push(visibleLength(" Open the Dashboard URL in your browser. Ctrl+C to stop."));
200
+
201
+ const contentWidth = Math.min(maxBox - 4, Math.max(...plainWidths, 40));
202
+ const totalWidth = contentWidth + 4;
203
+
204
+ /** @type {string[]} */
205
+ const styledRows = [];
206
+ styledRows.push("");
207
+ styledRows.push(...LOGO);
208
+ styledRows.push("");
209
+ styledRows.push(
210
+ ...row(color.label("Scan path"), scanPlain, contentWidth, color.value),
211
+ );
212
+ styledRows.push(
213
+ ...row(color.label("Report file"), reportPlain, contentWidth, color.value),
214
+ );
215
+ styledRows.push("");
216
+ if (opts.dashboardUrl) {
217
+ styledRows.push(
218
+ ...row(color.label("Dashboard"), opts.dashboardUrl, contentWidth, color.url),
219
+ );
220
+ }
221
+ if (opts.bundledUrl) {
222
+ const status = opts.apiAvailable ? color.ok(bundledStatusPlain) : color.warn(bundledStatusPlain);
223
+ styledRows.push(
224
+ ...row(
225
+ color.label("Bundled UI"),
226
+ `${opts.bundledUrl} (${bundledStatusPlain})`,
227
+ contentWidth,
228
+ () => `${color.url(opts.bundledUrl)} ${color.dim("(")}${status}${color.dim(")")}`,
229
+ ),
230
+ );
231
+ }
232
+ const apiStatus = opts.apiAvailable ? color.ok(apiStatusPlain) : color.err(apiStatusPlain);
233
+ styledRows.push(
234
+ ...row(
235
+ color.label("Scanner API"),
236
+ `${apiBase} (${apiStatusPlain})`,
237
+ contentWidth,
238
+ () => `${color.url(apiBase)} ${color.dim("(")}${apiStatus}${color.dim(")")}`,
239
+ ),
240
+ );
241
+ if (opts.apiAvailable) {
242
+ styledRows.push(
243
+ ...row("", `${apiBase}/dslint-report.json`, contentWidth, color.dim),
244
+ );
245
+ styledRows.push(...row("", `${apiBase}/events`, contentWidth, color.dim));
246
+ }
247
+ if (opts.pollMs) {
248
+ styledRows.push("");
249
+ styledRows.push(
250
+ ...row(color.label("Watch"), `polling every ${opts.pollMs} ms`, contentWidth, color.dim),
251
+ );
252
+ }
253
+ styledRows.push("");
254
+ styledRows.push(color.dim(" Open the Dashboard URL in your browser. Ctrl+C to stop."));
255
+
256
+ return boxLines(styledRows, totalWidth).join("\n");
257
+ }
258
+
259
+ /**
260
+ * @param {Parameters<typeof formatDevBanner>[0]} opts
261
+ */
262
+ export function writeDevBanner(opts) {
263
+ process.stderr.write(`${formatDevBanner(opts)}\n`);
264
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatDevBanner,
4
+ LOGO,
5
+ shortenPath,
6
+ visibleLength,
7
+ } from "./dev-banner.mjs";
8
+
9
+ describe("shortenPath", () => {
10
+ it("replaces home with tilde", () => {
11
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
12
+ if (!home) return;
13
+ expect(shortenPath(`${home}/Projects/foo`, 80)).toBe("~/Projects/foo");
14
+ });
15
+
16
+ it("truncates long paths in the middle", () => {
17
+ const long = "/very/long/path/segment/that/exceeds/the/maximum/allowed/length/for/display";
18
+ const out = shortenPath(long, 40);
19
+ expect(out.length).toBeLessThanOrEqual(40);
20
+ expect(out).toContain("…");
21
+ });
22
+ });
23
+
24
+ describe("formatDevBanner", () => {
25
+ it("includes logo, scan path, report, dashboard, and API URLs", () => {
26
+ const text = formatDevBanner({
27
+ scanPath: "/tmp/components",
28
+ reportPath: "/tmp/components/public/dslint-report.json",
29
+ apiPort: 7878,
30
+ apiAvailable: true,
31
+ dashboardUrl: "http://localhost:5173/",
32
+ bundledUrl: "http://127.0.0.1:7878/",
33
+ pollMs: 150,
34
+ });
35
+ expect(text).toContain(LOGO[0]);
36
+ expect(text).toContain(LOGO[1]);
37
+ expect(text).toContain("Scan path");
38
+ expect(text).toContain("Report file");
39
+ expect(text).toContain("Dashboard");
40
+ expect(text).toContain("http://localhost:5173/");
41
+ expect(text).toContain("7878");
42
+ expect(text).toContain("dslint-report.json");
43
+ expect(text).toContain("polling every 150 ms");
44
+ });
45
+
46
+ it("marks API unavailable when port is busy", () => {
47
+ const text = formatDevBanner({
48
+ scanPath: ".",
49
+ reportPath: "./public/dslint-report.json",
50
+ apiPort: 7878,
51
+ apiAvailable: false,
52
+ dashboardUrl: "http://localhost:5174/",
53
+ });
54
+ expect(text).toContain("unavailable");
55
+ expect(text).not.toContain("/events");
56
+ });
57
+
58
+ it("keeps right border aligned on every row", () => {
59
+ const text = formatDevBanner({
60
+ scanPath: "/very/long/path/that/could/push/the/box/wider/than/usual/Components",
61
+ reportPath: "/very/long/path/public/dslint-report.json",
62
+ apiPort: 7878,
63
+ apiAvailable: false,
64
+ dashboardUrl: "http://localhost:5175/",
65
+ bundledUrl: "http://127.0.0.1:7878/",
66
+ pollMs: 150,
67
+ });
68
+ const rows = text.split("\n").filter((l) => l.startsWith("│"));
69
+ const widths = rows.map((l) => visibleLength(l));
70
+ expect(new Set(widths).size).toBe(1);
71
+ expect(rows.every((l) => l.endsWith("│"))).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe("visibleLength", () => {
76
+ it("ignores ansi codes", () => {
77
+ expect(visibleLength("\u001b[32mok\u001b[0m")).toBe(2);
78
+ expect(visibleLength("\u001b[4;36mhttp://x\u001b[0m")).toBe(8);
79
+ });
80
+ });
@@ -3,21 +3,51 @@ import { spawnSync } from "node:child_process";
3
3
 
4
4
  /**
5
5
  * @param {number} port
6
- * @param {string} [host]
6
+ * @param {string} host
7
7
  */
8
- export function isPortInUse(port, host = "127.0.0.1") {
8
+ function isListening(port, host) {
9
9
  return new Promise((resolve) => {
10
- const server = net.createServer();
11
- server.once("error", (err) => {
12
- resolve(err && "code" in err && err.code === "EADDRINUSE");
10
+ const socket = net.connect({ port, host }, () => {
11
+ socket.destroy();
12
+ resolve(true);
13
13
  });
14
- server.once("listening", () => {
15
- server.close(() => resolve(false));
14
+ socket.once("error", () => resolve(false));
15
+ socket.setTimeout(400, () => {
16
+ socket.destroy();
17
+ resolve(false);
16
18
  });
17
- server.listen(port, host);
18
19
  });
19
20
  }
20
21
 
22
+ /**
23
+ * Whether something is already accepting connections on `port`.
24
+ * @param {number} port
25
+ * @param {string} [host] When set, only that address (e.g. `127.0.0.1` for the Rust scanner).
26
+ */
27
+ export async function isPortInUse(port, host) {
28
+ if (host) return isListening(port, host);
29
+ if (await isListening(port, "127.0.0.1")) return true;
30
+ try {
31
+ if (await isListening(port, "::1")) return true;
32
+ } catch {
33
+ // IPv6 unavailable
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * @param {number} start
40
+ * @param {number} [tries]
41
+ */
42
+ export async function findAvailablePort(start, tries = 32) {
43
+ for (let i = 0; i < tries; i++) {
44
+ const port = start + i;
45
+ if (port > 65535) break;
46
+ if (!(await isPortInUse(port))) return port;
47
+ }
48
+ throw new Error(`dslinter: no free port found near ${start}`);
49
+ }
50
+
21
51
  /**
22
52
  * @param {number} port
23
53
  */
@@ -39,19 +69,22 @@ export function describePortOccupant(port) {
39
69
 
40
70
  /**
41
71
  * @param {number} port
72
+ * @param {{ silent?: boolean }} [opts]
42
73
  */
43
- export async function warnIfPortBusy(port) {
44
- if (!(await isPortInUse(port))) return false;
45
- const detail = describePortOccupant(port);
46
- process.stderr.write(
47
- [
48
- "",
49
- `[dslinter] Port ${port} is already in use — scanner cannot bind.`,
50
- detail ? ` ${detail.replace(/\n/g, "\n ")}` : "",
51
- ` Stop the old process, then restart. Example: lsof -nP -iTCP:${port} -sTCP:LISTEN`,
52
- " If you already have `npm run dev` running, open the Vite URL it printed (e.g. http://localhost:5173/).",
53
- "",
54
- ].join("\n"),
55
- );
74
+ export async function warnIfPortBusy(port, opts = {}) {
75
+ if (!(await isPortInUse(port, "127.0.0.1"))) return false;
76
+ if (!opts.silent) {
77
+ const detail = describePortOccupant(port);
78
+ process.stderr.write(
79
+ [
80
+ "",
81
+ `[dslinter] Port ${port} is already in use — scanner cannot bind.`,
82
+ detail ? ` ${detail.replace(/\n/g, "\n ")}` : "",
83
+ ` Stop the old process, then restart. Example: lsof -nP -iTCP:${port} -sTCP:LISTEN`,
84
+ " If you already have `npm run dev` running, open the Vite URL it printed (e.g. http://localhost:5173/).",
85
+ "",
86
+ ].join("\n"),
87
+ );
88
+ }
56
89
  return true;
57
90
  }
@@ -37,11 +37,12 @@ export async function spawnScanner(scannerArgs, options = {}) {
37
37
 
38
38
  return spawn(process.execPath, [binScript, ...scannerArgs], {
39
39
  stdio: "inherit",
40
+ ...options,
40
41
  env: {
41
42
  ...process.env,
42
43
  DSLINTER_INTERNAL: "1",
44
+ ...options.env,
43
45
  },
44
- ...options,
45
46
  });
46
47
  }
47
48