@narumitw/pi-lsp 0.1.19

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # 🧠 pi-lsp — Shared Language Server Tools for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-lsp)](https://www.npmjs.com/package/@narumitw/pi-lsp) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-lsp` is a native [Pi coding agent](https://pi.dev) extension that exposes Biome, ty, and Ruff language-server tools through one shared LSP runner Module.
6
+
7
+ It is intended to cover the current behavior of `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp` while keeping those older packages available and unchanged for now.
8
+
9
+ ## ✨ Features
10
+
11
+ - Runs `biome lsp-proxy` on demand for diagnostics, formatting, import organization, and source fixes.
12
+ - Runs `ty server` on demand for Python type diagnostics.
13
+ - Runs `ruff server` on demand for Python lint diagnostics, formatting, import organization, and source fixes.
14
+ - Uses one internal LSP runner for JSON-RPC framing, subprocess lifecycle, diagnostics, formatting, code actions, and workspace edit application.
15
+ - Keeps Biome, ty, and Ruff behavior in small server Adapters.
16
+ - Supports workspace roots, file limits, recursive file discovery, and write-or-preview edits.
17
+ - Starts language servers only for tool calls, then shuts them down.
18
+ - Shows statusline activity only while LSP tools are running.
19
+
20
+ ## 📦 Install
21
+
22
+ ```bash
23
+ pi install npm:@narumitw/pi-lsp
24
+ ```
25
+
26
+ Try without installing permanently:
27
+
28
+ ```bash
29
+ pi -e npm:@narumitw/pi-lsp
30
+ ```
31
+
32
+ Try this package locally from the repository root:
33
+
34
+ ```bash
35
+ pi -e ./extensions/pi-lsp
36
+ ```
37
+
38
+ ## ⚠️ Tool-name compatibility
39
+
40
+ This package intentionally registers the same tool names as `@narumitw/pi-biome-lsp` and `@narumitw/pi-python-lsp`:
41
+
42
+ - `biome_lsp_diagnostics`
43
+ - `biome_lsp_format`
44
+ - `biome_lsp_fix`
45
+ - `ty_lsp_diagnostics`
46
+ - `ruff_lsp_diagnostics`
47
+ - `ruff_lsp_format`
48
+ - `ruff_lsp_fix`
49
+
50
+ Avoid installing `@narumitw/pi-lsp` side by side with the older LSP packages unless you have verified how your Pi version handles duplicate tool names. The older packages are not deprecated in this phase. For the same reason, this repository's `just install-all` recipe skips `pi-lsp`; install `pi-lsp` separately when you want the shared LSP extension instead of the older split packages.
51
+
52
+ ## ✅ Requirements
53
+
54
+ Install the language servers you want to use somewhere on `PATH`:
55
+
56
+ ```bash
57
+ uv tool install ty
58
+ uv tool install ruff
59
+ ```
60
+
61
+ For Biome, either install it globally/on `PATH`, add your project's `node_modules/.bin` to `PATH`, or point the extension at a project-local command. For example:
62
+
63
+ ```bash
64
+ npm install -D @biomejs/biome
65
+ PI_BIOME_LSP_COMMAND="./node_modules/.bin/biome lsp-proxy" pi -e ./extensions/pi-lsp
66
+ ```
67
+
68
+ Or provide custom server commands:
69
+
70
+ ```bash
71
+ PI_BIOME_LSP_COMMAND="npx biome lsp-proxy" \
72
+ PI_TY_LSP_COMMAND="uvx ty server" \
73
+ PI_RUFF_LSP_COMMAND="uvx ruff server" \
74
+ pi -e ./extensions/pi-lsp
75
+ ```
76
+
77
+ Optional timeout overrides:
78
+
79
+ ```bash
80
+ PI_BIOME_LSP_TIMEOUT_MS=30000 \
81
+ PI_TY_LSP_TIMEOUT_MS=30000 \
82
+ PI_RUFF_LSP_TIMEOUT_MS=30000 \
83
+ pi -e ./extensions/pi-lsp
84
+ ```
85
+
86
+ ## 🛠️ Pi tools
87
+
88
+ ### Biome
89
+
90
+ - `biome_lsp_diagnostics` — start `biome lsp-proxy`, open supported files, and return diagnostics.
91
+ - `biome_lsp_format` — compute or write formatting edits for one Biome-supported file.
92
+ - `biome_lsp_fix` — compute or write source actions such as `source.fixAll.biome` or `source.organizeImports.biome`.
93
+
94
+ ### Python
95
+
96
+ - `ty_lsp_diagnostics` — start `ty server`, open Python files, and return type diagnostics.
97
+ - `ruff_lsp_diagnostics` — start `ruff server`, open Python files, and return lint diagnostics.
98
+ - `ruff_lsp_format` — compute or write Ruff formatting edits for one Python file.
99
+ - `ruff_lsp_fix` — compute or write Ruff source actions such as `source.fixAll.ruff` or `source.organizeImports.ruff`.
100
+
101
+ ## 🚀 Examples
102
+
103
+ Check a project subset with Biome diagnostics:
104
+
105
+ ```json
106
+ {
107
+ "paths": ["src", "extensions/pi-lsp/src"],
108
+ "limit": 100
109
+ }
110
+ ```
111
+
112
+ Format a TypeScript file with Biome:
113
+
114
+ ```json
115
+ {
116
+ "path": "src/index.ts",
117
+ "write": true
118
+ }
119
+ ```
120
+
121
+ Check a Python project with ty or Ruff diagnostics:
122
+
123
+ ```json
124
+ {
125
+ "paths": ["src", "tests"],
126
+ "limit": 100
127
+ }
128
+ ```
129
+
130
+ Organize Python imports with Ruff:
131
+
132
+ ```json
133
+ {
134
+ "path": "src/app.py",
135
+ "kind": "source.organizeImports.ruff",
136
+ "write": true
137
+ }
138
+ ```
139
+
140
+ If `paths` is omitted for diagnostics, the tool recursively discovers supported files under the workspace root while skipping common generated, dependency, cache, and virtualenv directories.
141
+
142
+ ## 💬 Command
143
+
144
+ ```text
145
+ /lsp
146
+ ```
147
+
148
+ Shows the configured Biome, ty, and Ruff LSP commands and whether each command is available on `PATH`.
149
+
150
+ ## 🗂️ Package layout
151
+
152
+ ```txt
153
+ extensions/pi-lsp/
154
+ ├── src/
155
+ │ ├── adapters.ts
156
+ │ ├── command.ts
157
+ │ ├── files.ts
158
+ │ ├── lsp-client.ts
159
+ │ ├── pi-lsp.ts
160
+ │ ├── runner.ts
161
+ │ ├── text-edits.ts
162
+ │ └── types.ts
163
+ ├── README.md
164
+ ├── LICENSE
165
+ ├── tsconfig.json
166
+ └── package.json
167
+ ```
168
+
169
+ ## 🔎 Keywords
170
+
171
+ Pi extension, Pi coding agent, Language Server Protocol, Biome LSP, ty, Ruff, Python LSP, formatter, linter, import organization, AI coding tools.
172
+
173
+ ## 📄 License
174
+
175
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@narumitw/pi-lsp",
3
+ "version": "0.1.19",
4
+ "description": "Pi extension that exposes language-server tools for Biome, ty, and Ruff through a shared LSP runner.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "lsp",
13
+ "biome",
14
+ "python",
15
+ "ty",
16
+ "ruff",
17
+ "lint",
18
+ "format"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./src/pi-lsp.ts"
28
+ ]
29
+ },
30
+ "scripts": {
31
+ "check": "biome check . && npm run typecheck",
32
+ "format": "biome check --write .",
33
+ "typecheck": "tsc --noEmit"
34
+ },
35
+ "dependencies": {
36
+ "typebox": "^1.1.37"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "2.4.14",
40
+ "@earendil-works/pi-coding-agent": "0.74.0",
41
+ "@types/node": "25.6.0",
42
+ "typescript": "6.0.3"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/narumiruna/pi-extensions",
47
+ "directory": "extensions/pi-lsp"
48
+ }
49
+ }
@@ -0,0 +1,141 @@
1
+ import path from "node:path";
2
+ import type { LspServerAdapter } from "./types.js";
3
+
4
+ const BIOME_SKIP_DIRECTORIES = new Set([
5
+ ".git",
6
+ ".hg",
7
+ ".next",
8
+ ".nuxt",
9
+ ".output",
10
+ ".svelte-kit",
11
+ "coverage",
12
+ "dist",
13
+ "node_modules",
14
+ "out",
15
+ ]);
16
+ const BIOME_SUPPORTED_EXTENSIONS = new Set([
17
+ ".astro",
18
+ ".css",
19
+ ".cts",
20
+ ".cjs",
21
+ ".graphql",
22
+ ".gql",
23
+ ".html",
24
+ ".js",
25
+ ".json",
26
+ ".jsonc",
27
+ ".jsx",
28
+ ".mjs",
29
+ ".mts",
30
+ ".svelte",
31
+ ".ts",
32
+ ".tsx",
33
+ ".vue",
34
+ ]);
35
+
36
+ const PYTHON_SKIP_DIRECTORIES = new Set([
37
+ ".git",
38
+ ".hg",
39
+ ".mypy_cache",
40
+ ".ruff_cache",
41
+ ".tox",
42
+ ".venv",
43
+ "__pycache__",
44
+ "node_modules",
45
+ "venv",
46
+ ]);
47
+
48
+ export const biomeAdapter: LspServerAdapter = {
49
+ label: "Biome",
50
+ statusPrefix: "🧬",
51
+ defaultCommand: { command: "biome", args: ["lsp-proxy"] },
52
+ commandEnvVar: "PI_BIOME_LSP_COMMAND",
53
+ timeoutEnvVar: "PI_BIOME_LSP_TIMEOUT_MS",
54
+ missingCommandHint: "Install @biomejs/biome or set PI_BIOME_LSP_COMMAND.",
55
+ skipDirectories: BIOME_SKIP_DIRECTORIES,
56
+ isSupportedFile: (filePath) => BIOME_SUPPORTED_EXTENSIONS.has(path.extname(filePath)),
57
+ languageIdFor: biomeLanguageIdFor,
58
+ formattingOptions: { tabSize: 2, insertSpaces: false },
59
+ initialize: {
60
+ codeAction: true,
61
+ diagnosticDynamicRegistration: true,
62
+ formattingDynamicRegistration: true,
63
+ codeActionDynamicRegistration: true,
64
+ didChangeConfigurationDynamicRegistration: true,
65
+ didSaveDynamicRegistration: true,
66
+ },
67
+ fallbackToPublishDiagnostics: true,
68
+ resolveUnsupportedCodeActions: true,
69
+ serverRequestWorkspaceFolders: true,
70
+ emptyDiagnosticsMessage: "Biome LSP found no supported files to check.",
71
+ formatDiagnosticsHeader: (summary) =>
72
+ `Biome LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
73
+ editSummaryLabel: "Biome",
74
+ defaultFixKind: "source.fixAll.biome",
75
+ };
76
+
77
+ export const tyAdapter: LspServerAdapter = {
78
+ label: "ty",
79
+ statusPrefix: "🐍 ty",
80
+ defaultCommand: { command: "ty", args: ["server"] },
81
+ commandEnvVar: "PI_TY_LSP_COMMAND",
82
+ timeoutEnvVar: "PI_TY_LSP_TIMEOUT_MS",
83
+ missingCommandHint: "Install ty or set PI_TY_LSP_COMMAND.",
84
+ skipDirectories: PYTHON_SKIP_DIRECTORIES,
85
+ isSupportedFile: isPythonFile,
86
+ languageIdFor: () => "python",
87
+ formattingOptions: { tabSize: 4, insertSpaces: true },
88
+ initialize: {
89
+ codeAction: false,
90
+ diagnosticDynamicRegistration: false,
91
+ },
92
+ fallbackToPublishDiagnostics: false,
93
+ resolveUnsupportedCodeActions: false,
94
+ serverRequestWorkspaceFolders: false,
95
+ emptyDiagnosticsMessage: "ty LSP found no Python files to check.",
96
+ formatDiagnosticsHeader: (summary) =>
97
+ `ty LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
98
+ editSummaryLabel: "ty",
99
+ };
100
+
101
+ export const ruffAdapter: LspServerAdapter = {
102
+ label: "Ruff",
103
+ statusPrefix: "🐍 ruff",
104
+ defaultCommand: { command: "ruff", args: ["server"] },
105
+ commandEnvVar: "PI_RUFF_LSP_COMMAND",
106
+ timeoutEnvVar: "PI_RUFF_LSP_TIMEOUT_MS",
107
+ missingCommandHint: "Install ruff or set PI_RUFF_LSP_COMMAND.",
108
+ skipDirectories: PYTHON_SKIP_DIRECTORIES,
109
+ isSupportedFile: isPythonFile,
110
+ languageIdFor: () => "python",
111
+ formattingOptions: { tabSize: 4, insertSpaces: true },
112
+ initialize: {
113
+ codeAction: true,
114
+ diagnosticDynamicRegistration: false,
115
+ },
116
+ fallbackToPublishDiagnostics: false,
117
+ resolveUnsupportedCodeActions: false,
118
+ serverRequestWorkspaceFolders: false,
119
+ emptyDiagnosticsMessage: "Ruff LSP found no Python files to check.",
120
+ formatDiagnosticsHeader: (summary) =>
121
+ `Ruff LSP diagnostics: ${summary.diagnostics} diagnostic(s) across ${summary.files} file(s).`,
122
+ editSummaryLabel: "Ruff",
123
+ defaultFixKind: "source.fixAll.ruff",
124
+ };
125
+
126
+ export const adapters = [biomeAdapter, tyAdapter, ruffAdapter] as const;
127
+
128
+ function biomeLanguageIdFor(filePath: string) {
129
+ const extension = path.extname(filePath);
130
+ if (extension === ".js" || extension === ".cjs" || extension === ".mjs") return "javascript";
131
+ if (extension === ".jsx") return "javascriptreact";
132
+ if (extension === ".ts" || extension === ".cts" || extension === ".mts") return "typescript";
133
+ if (extension === ".tsx") return "typescriptreact";
134
+ if (extension === ".gql") return "graphql";
135
+ if (extension === ".jsonc") return "jsonc";
136
+ return extension.slice(1);
137
+ }
138
+
139
+ function isPythonFile(filePath: string) {
140
+ return filePath.endsWith(".py") || filePath.endsWith(".pyi");
141
+ }
package/src/command.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { accessSync, constants, existsSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import type { ServerCommand } from "./types.js";
5
+
6
+ export function commandFromEnv(envVar: string, fallback: ServerCommand): ServerCommand {
7
+ const customCommand = process.env[envVar]?.trim();
8
+ if (customCommand) {
9
+ const [command, ...args] = splitCommand(customCommand);
10
+ if (command) return { command, args };
11
+ }
12
+
13
+ return fallback;
14
+ }
15
+
16
+ export function timeoutFromEnv(envVar: string, defaultTimeoutMs: number) {
17
+ const rawValue = Number(process.env[envVar] ?? defaultTimeoutMs);
18
+ return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : defaultTimeoutMs;
19
+ }
20
+
21
+ export function commandExists(command: string, cwd = process.cwd()) {
22
+ if (command.includes("/") || command.includes("\\")) {
23
+ return isRunnableFile(path.isAbsolute(command) ? command : path.resolve(cwd, command));
24
+ }
25
+
26
+ const pathValue = process.env.PATH ?? "";
27
+ const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
28
+ for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
29
+ if (!directory) continue;
30
+ for (const extension of extensions) {
31
+ if (isRunnableFile(path.join(directory, `${command}${extension}`))) return true;
32
+ }
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ function isRunnableFile(filePath: string) {
39
+ if (!existsSync(filePath)) return false;
40
+ try {
41
+ if (!statSync(filePath).isFile()) return false;
42
+ if (process.platform !== "win32") accessSync(filePath, constants.X_OK);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export function splitCommand(input: string) {
50
+ const parts: string[] = [];
51
+ let current = "";
52
+ let quote: '"' | "'" | undefined;
53
+
54
+ for (let index = 0; index < input.length; index += 1) {
55
+ const char = input[index];
56
+ const next = input[index + 1];
57
+
58
+ if (char === "\\" && next !== undefined && shouldEscapeNextCharacter(next, quote)) {
59
+ current += next;
60
+ index += 1;
61
+ continue;
62
+ }
63
+
64
+ if ((char === '"' || char === "'") && !quote) {
65
+ quote = char;
66
+ continue;
67
+ }
68
+
69
+ if (char === quote) {
70
+ quote = undefined;
71
+ continue;
72
+ }
73
+
74
+ if (/\s/.test(char) && !quote) {
75
+ if (current) {
76
+ parts.push(current);
77
+ current = "";
78
+ }
79
+ continue;
80
+ }
81
+
82
+ current += char;
83
+ }
84
+
85
+ if (current) parts.push(current);
86
+ return parts;
87
+ }
88
+
89
+ function shouldEscapeNextCharacter(next: string, quote: '"' | "'" | undefined) {
90
+ if (next === "\\") return true;
91
+ if (quote) return next === quote;
92
+ return next === '"' || next === "'" || /\s/.test(next);
93
+ }
package/src/files.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { LspServerAdapter } from "./types.js";
6
+
7
+ export function resolveRoot(root?: string) {
8
+ const resolvedRoot = path.resolve(root?.trim() || process.cwd());
9
+ if (!existsSync(resolvedRoot)) throw new Error(`Workspace root does not exist: ${resolvedRoot}`);
10
+ if (!statSync(resolvedRoot).isDirectory()) {
11
+ throw new Error(`Expected workspace root to be a directory: ${resolvedRoot}`);
12
+ }
13
+ return resolvedRoot;
14
+ }
15
+
16
+ export function directoryUri(directory: string) {
17
+ return pathToFileURL(directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`).href;
18
+ }
19
+
20
+ export function resolveSupportedFile(adapter: LspServerAdapter, root: string, filePath: string) {
21
+ const resolvedPath = resolveWorkspacePath(root, filePath, "File path");
22
+ if (!existsSync(resolvedPath)) throw new Error(`${adapter.label} file does not exist: ${resolvedPath}`);
23
+ if (!isInsidePath(realpathSync(root), realpathSync(resolvedPath))) {
24
+ throw new Error(`File resolves outside workspace root: ${resolvedPath}`);
25
+ }
26
+ if (!statSync(resolvedPath).isFile()) throw new Error(`Expected a file: ${resolvedPath}`);
27
+ if (!adapter.isSupportedFile(resolvedPath)) {
28
+ throw new Error(`Expected a ${adapter.label} supported file: ${resolvedPath}`);
29
+ }
30
+ return resolvedPath;
31
+ }
32
+
33
+ export function collectSupportedFiles(
34
+ adapter: LspServerAdapter,
35
+ root: string,
36
+ requestedPaths: string[] | undefined,
37
+ limit: number,
38
+ ) {
39
+ const cappedLimit = Math.max(1, Math.floor(limit));
40
+ const files: string[] = [];
41
+ const seen = new Set<string>();
42
+ const visitedDirectories = new Set<string>();
43
+ const realRoot = realpathSync(root);
44
+ const inputs = requestedPaths?.length ? requestedPaths : [root];
45
+
46
+ for (const input of inputs) {
47
+ const targetPath = resolveWorkspacePath(root, input, "Requested path");
48
+ if (!existsSync(targetPath)) throw new Error(`Requested path does not exist: ${targetPath}`);
49
+ if (!isInsidePath(realRoot, realpathSync(targetPath))) {
50
+ throw new Error(`Requested path resolves outside workspace root: ${targetPath}`);
51
+ }
52
+ collectPath(adapter, targetPath, files, seen, visitedDirectories, realRoot, cappedLimit);
53
+ if (files.length >= cappedLimit) break;
54
+ }
55
+
56
+ return files;
57
+ }
58
+
59
+ function collectPath(
60
+ adapter: LspServerAdapter,
61
+ targetPath: string,
62
+ files: string[],
63
+ seen: Set<string>,
64
+ visitedDirectories: Set<string>,
65
+ realRoot: string,
66
+ limit: number,
67
+ ) {
68
+ if (files.length >= limit || !existsSync(targetPath)) return;
69
+ if (!isInsidePath(realRoot, realpathSync(targetPath))) return;
70
+
71
+ const stats = statSync(targetPath);
72
+ if (stats.isFile()) {
73
+ if (adapter.isSupportedFile(targetPath) && !seen.has(targetPath)) {
74
+ seen.add(targetPath);
75
+ files.push(targetPath);
76
+ }
77
+ return;
78
+ }
79
+
80
+ if (!stats.isDirectory()) return;
81
+ const directoryKey = realpathSync(targetPath);
82
+ if (visitedDirectories.has(directoryKey)) return;
83
+ visitedDirectories.add(directoryKey);
84
+
85
+ const entries = readdirSync(targetPath, { withFileTypes: true }).sort((left, right) =>
86
+ left.name.localeCompare(right.name),
87
+ );
88
+ for (const entry of entries) {
89
+ if (files.length >= limit) break;
90
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && adapter.skipDirectories.has(entry.name)) continue;
91
+ collectPath(adapter, path.join(targetPath, entry.name), files, seen, visitedDirectories, realRoot, limit);
92
+ }
93
+ }
94
+
95
+ function resolveWorkspacePath(root: string, inputPath: string, label: string) {
96
+ const resolvedPath = path.resolve(root, inputPath);
97
+ const realRoot = realpathSync(root);
98
+ const isLexicallyInsideRoot = isInsidePath(root, resolvedPath);
99
+
100
+ if (existsSync(resolvedPath)) {
101
+ const realResolvedPath = realpathSync(resolvedPath);
102
+ if (!isInsidePath(realRoot, realResolvedPath)) {
103
+ throw new Error(`${label} resolves outside workspace root: ${resolvedPath}`);
104
+ }
105
+ return isLexicallyInsideRoot
106
+ ? resolvedPath
107
+ : path.join(root, path.relative(realRoot, realResolvedPath));
108
+ }
109
+
110
+ if (!isLexicallyInsideRoot && !isInsidePath(realRoot, resolvedPath)) {
111
+ throw new Error(`${label} escapes workspace root: ${resolvedPath}`);
112
+ }
113
+ return resolvedPath;
114
+ }
115
+
116
+ function isInsidePath(parent: string, child: string) {
117
+ const relativePath = path.relative(parent, child);
118
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
119
+ }