@danypops/lector 0.1.7 → 0.1.8

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.
Files changed (35) hide show
  1. package/README.md +9 -7
  2. package/package.json +5 -1
  3. package/src/adapters/fallback-code-intelligence-index.ts +73 -0
  4. package/src/adapters/find-source-files.ts +1 -1
  5. package/src/adapters/git-repo-fetcher.ts +154 -37
  6. package/src/adapters/lsp/json-rpc-stream.ts +52 -2
  7. package/src/adapters/lsp/language-server-process.ts +54 -10
  8. package/src/adapters/lsp/lsp-symbol-index.ts +129 -30
  9. package/src/adapters/normalize-npm-repository.ts +77 -0
  10. package/src/adapters/npm-lockfile-version-resolver.ts +519 -0
  11. package/src/adapters/npm-package-source-resolver.ts +322 -0
  12. package/src/adapters/npm-registry-client.ts +304 -0
  13. package/src/adapters/sqlite-symbol-graph.ts +47 -3
  14. package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +61 -11
  15. package/src/adapters/typescript-compiler-symbol-index.ts +129 -0
  16. package/src/cli.ts +87 -23
  17. package/src/daemon.ts +4 -0
  18. package/src/domain/find-workspace-symbols.ts +4 -3
  19. package/src/domain/installed-package-version.ts +66 -0
  20. package/src/domain/intelligence-provenance.ts +19 -0
  21. package/src/domain/language-server-descriptor.ts +18 -0
  22. package/src/domain/npm-package-metadata.ts +26 -0
  23. package/src/domain/package-source.ts +143 -0
  24. package/src/domain/repo-fetch-result.ts +33 -1
  25. package/src/domain/resolve-package-source.ts +204 -0
  26. package/src/domain/symbol-graph-generation.ts +3 -0
  27. package/src/domain/symbol-query.ts +16 -0
  28. package/src/domain/workspace-symbol.ts +8 -0
  29. package/src/index.ts +61 -4
  30. package/src/ports/installed-package-version-resolver-port.ts +5 -0
  31. package/src/ports/npm-registry-port.ts +5 -0
  32. package/src/ports/package-source-resolver-port.ts +5 -0
  33. package/src/ports/repo-fetcher-port.ts +2 -2
  34. package/src/ports/symbol-index-port.ts +4 -2
  35. package/src/service.ts +89 -25
@@ -1,5 +1,5 @@
1
1
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
- import { encodeJsonRpcMessage, type JsonRpcMessage, JsonRpcStreamDecoder } from "./json-rpc-stream.ts";
2
+ import { encodeJsonRpcMessage, type JsonRpcMessage, JsonRpcMessageLimitExceeded, JsonRpcStreamDecoder } from "./json-rpc-stream.ts";
3
3
 
4
4
  export interface LanguageServerProcessOptions {
5
5
  command: string;
@@ -9,6 +9,10 @@ export interface LanguageServerProcessOptions {
9
9
  env?: Readonly<Record<string, string | undefined>>;
10
10
  /** Per-request timeout. Default 10s. */
11
11
  requestTimeoutMs?: number;
12
+ /** Maximum simultaneous requests. Default 64. */
13
+ maxPendingRequests?: number;
14
+ /** Maximum decoded server message size. Default 8 MiB. */
15
+ maxMessageBytes?: number;
12
16
  }
13
17
 
14
18
  /** Raised when a request is sent (or was pending) after the server process already exited. */
@@ -20,6 +24,13 @@ export class LanguageServerProcessExited extends Error {
20
24
  }
21
25
 
22
26
  /** Raised when a request does not receive a response within its timeout. */
27
+ export class LanguageServerCapacityExceeded extends Error {
28
+ constructor(readonly maxPendingRequests: number) {
29
+ super(`language server request capacity reached (${maxPendingRequests} pending)`);
30
+ this.name = "LanguageServerCapacityExceeded";
31
+ }
32
+ }
33
+
23
34
  export class LanguageServerRequestTimedOut extends Error {
24
35
  constructor(
25
36
  readonly method: string,
@@ -51,23 +62,34 @@ type NotificationHandler = (params: unknown) => void;
51
62
  */
52
63
  export class LanguageServerProcess {
53
64
  private readonly child: ChildProcessWithoutNullStreams;
54
- private readonly decoder = new JsonRpcStreamDecoder();
65
+ private readonly decoder: JsonRpcStreamDecoder;
55
66
  private readonly pending = new Map<number, PendingRequest>();
56
67
  private readonly notificationHandlers = new Map<string, Set<NotificationHandler>>();
57
68
  private readonly requestTimeoutMs: number;
69
+ private readonly maxPendingRequests: number;
70
+ private readonly maxMessageBytes: number;
58
71
  private nextId = 1;
59
72
  private processExited = false;
60
73
  private exitError: Error;
61
74
 
62
- private constructor(child: ChildProcessWithoutNullStreams, label: string, requestTimeoutMs: number) {
75
+ private constructor(child: ChildProcessWithoutNullStreams, label: string, requestTimeoutMs: number, maxPendingRequests: number, maxMessageBytes: number) {
63
76
  this.child = child;
64
77
  this.requestTimeoutMs = requestTimeoutMs;
78
+ this.maxPendingRequests = maxPendingRequests;
79
+ this.maxMessageBytes = maxMessageBytes;
80
+ this.decoder = new JsonRpcStreamDecoder({ maxMessageBytes });
65
81
  this.exitError = new LanguageServerProcessExited(label);
82
+ this.child.stderr.resume();
66
83
 
67
84
  this.child.stdout.on("data", (chunk: Buffer) => {
68
- for (const message of this.decoder.push(chunk)) this.dispatch(message);
85
+ try {
86
+ for (const message of this.decoder.push(chunk)) this.dispatch(message);
87
+ } catch (error) {
88
+ this.fail(error instanceof Error ? error : new Error(String(error)));
89
+ }
69
90
  });
70
91
 
92
+ this.child.once("error", (error) => this.fail(error));
71
93
  this.child.once("exit", () => {
72
94
  this.processExited = true;
73
95
  for (const [id, request] of this.pending) {
@@ -84,14 +106,18 @@ export class LanguageServerProcess {
84
106
  }
85
107
 
86
108
  static spawnProcess(options: LanguageServerProcessOptions): LanguageServerProcess {
109
+ const maxPendingRequests = options.maxPendingRequests ?? 64;
110
+ const maxMessageBytes = options.maxMessageBytes ?? 8 * 1024 * 1024;
111
+ if (!Number.isSafeInteger(maxPendingRequests) || maxPendingRequests < 1) throw new TypeError("maxPendingRequests must be a positive safe integer");
112
+ if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes < 1) throw new TypeError("maxMessageBytes must be a positive safe integer");
87
113
  const child = spawn(options.command, [...(options.args ?? [])], {
88
114
  cwd: options.cwd,
89
115
  env: { ...process.env, ...options.env },
90
116
  stdio: ["pipe", "pipe", "pipe"],
91
117
  // Own process group on POSIX so stop() can kill every descendant as a unit.
92
118
  detached: process.platform !== "win32",
93
- }) as ChildProcessWithoutNullStreams;
94
- return new LanguageServerProcess(child, options.command, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
119
+ });
120
+ return new LanguageServerProcess(child, options.command, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, maxPendingRequests, maxMessageBytes);
95
121
  }
96
122
 
97
123
  private dispatch(message: JsonRpcMessage): void {
@@ -120,20 +146,25 @@ export class LanguageServerProcess {
120
146
 
121
147
  async request<T>(method: string, params: unknown): Promise<T> {
122
148
  if (this.processExited) throw this.exitError;
149
+ if (this.pending.size >= this.maxPendingRequests) throw new LanguageServerCapacityExceeded(this.maxPendingRequests);
123
150
  const id = this.nextId++;
151
+ const message = encodeJsonRpcMessage({ jsonrpc: "2.0", id, method, params });
152
+ if (message.byteLength > this.maxMessageBytes) throw new JsonRpcMessageLimitExceeded("message-bytes", this.maxMessageBytes, message.byteLength);
124
153
  return new Promise<T>((resolve, reject) => {
125
154
  const timer = setTimeout(() => {
126
155
  this.pending.delete(id);
127
156
  reject(new LanguageServerRequestTimedOut(method, this.requestTimeoutMs));
128
157
  }, this.requestTimeoutMs);
129
158
  this.pending.set(id, { resolve: resolve as (result: unknown) => void, reject, timer });
130
- this.child.stdin.write(encodeJsonRpcMessage({ jsonrpc: "2.0", id, method, params }));
159
+ this.child.stdin.write(message);
131
160
  });
132
161
  }
133
162
 
134
163
  notify(method: string, params: unknown): void {
135
164
  if (this.processExited) return;
136
- this.child.stdin.write(encodeJsonRpcMessage({ jsonrpc: "2.0", method, params }));
165
+ const message = encodeJsonRpcMessage({ jsonrpc: "2.0", method, params });
166
+ if (message.byteLength > this.maxMessageBytes) throw new JsonRpcMessageLimitExceeded("message-bytes", this.maxMessageBytes, message.byteLength);
167
+ this.child.stdin.write(message);
137
168
  }
138
169
 
139
170
  /**
@@ -142,6 +173,18 @@ export class LanguageServerProcess {
142
173
  * graceful path hung, errored, or the process simply ignored it -- kills
143
174
  * the entire process group so no descendant is left running.
144
175
  */
176
+ private fail(error: Error): void {
177
+ if (this.processExited) return;
178
+ this.processExited = true;
179
+ this.exitError = error;
180
+ for (const [id, request] of this.pending) {
181
+ clearTimeout(request.timer);
182
+ request.reject(error);
183
+ this.pending.delete(id);
184
+ }
185
+ this.killProcessGroup();
186
+ }
187
+
145
188
  async stop(stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<void> {
146
189
  if (this.processExited) return;
147
190
 
@@ -172,8 +215,9 @@ export class LanguageServerProcess {
172
215
 
173
216
  private killProcessGroup(): void {
174
217
  try {
175
- if (process.platform !== "win32" && this.child.pid) {
176
- process.kill(-this.child.pid, "SIGKILL"); // negative pid == the whole process group
218
+ const pid = this.child.pid;
219
+ if (process.platform !== "win32" && pid !== undefined) {
220
+ process.kill(-pid, "SIGKILL"); // negative pid == the whole process group
177
221
  } else {
178
222
  this.child.kill("SIGKILL");
179
223
  }
@@ -1,18 +1,49 @@
1
1
  import { readFileSync, realpathSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { extname, join } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "../../domain/call-hierarchy.ts";
5
5
  import type { CodeRange } from "../../domain/code-range.ts";
6
6
  import type { Diagnostic, DiagnosticSeverity } from "../../domain/diagnostic.ts";
7
7
  import type { DocumentSymbolEntry } from "../../domain/document-symbol.ts";
8
8
  import type { Hover } from "../../domain/hover.ts";
9
+ import type { IntelligenceProvenance, SymbolSearchBounds } from "../../domain/intelligence-provenance.ts";
9
10
  import { DEFAULT_SETTLE_MS, type LanguageServerDescriptor } from "../../domain/language-server-descriptor.ts";
10
- import type { WorkspaceLocation, WorkspaceSymbol } from "../../domain/workspace-symbol.ts";
11
+ import type { SymbolSearchResult, WorkspaceLocation, WorkspaceSymbol } from "../../domain/workspace-symbol.ts";
11
12
  import type { CodeIntelligencePort } from "../../ports/code-intelligence-port.ts";
12
13
  import type { SymbolIndexPort } from "../../ports/symbol-index-port.ts";
14
+ import { TypeScriptCompilerSymbolIndex } from "../typescript-compiler-symbol-index.ts";
13
15
  import { resolveSeedFile } from "./discover-seed-file.ts";
14
16
  import { LanguageServerProcess } from "./language-server-process.ts";
15
17
 
18
+ const DEFAULT_MAX_SYMBOL_RESULTS = 1_000;
19
+ const DEFAULT_MAX_OPEN_FILES = 256;
20
+ const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024;
21
+ const MAX_SETTLE_MS = 30_000;
22
+
23
+ export interface LspSymbolIndexOptions {
24
+ readonly maxOpenFiles?: number;
25
+ readonly maxFileBytes?: number;
26
+ readonly maxRefreshBytes?: number;
27
+ readonly maxFallbackSeedFiles?: number;
28
+ }
29
+
30
+ export class LanguageFileLimitExceeded extends Error {
31
+ constructor(
32
+ readonly limit: "open-files" | "file-bytes" | "refresh-bytes",
33
+ readonly max: number,
34
+ readonly observed: number,
35
+ ) {
36
+ super(`language intelligence ${limit} limit exceeded: ${observed} > ${max}`);
37
+ this.name = "LanguageFileLimitExceeded";
38
+ }
39
+ }
40
+
41
+ function positiveLimit(value: number | undefined, fallback: number, field: string): number {
42
+ const result = value ?? fallback;
43
+ if (!Number.isSafeInteger(result) || result < 1) throw new TypeError(`${field} must be a positive safe integer`);
44
+ return result;
45
+ }
46
+
16
47
  const LSP_SYMBOL_KIND_NAMES: Readonly<Record<number, string>> = {
17
48
  1: "file",
18
49
  2: "module",
@@ -237,19 +268,40 @@ function normalizeDocumentSymbol(path: string, item: LspDocumentSymbol | LspSymb
237
268
  * actually loaded -- open a file first to guarantee its usages are included.
238
269
  */
239
270
  export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
271
+ readonly provenance: IntelligenceProvenance;
240
272
  private readonly cwd: string;
241
273
  private readonly descriptor: LanguageServerDescriptor;
242
274
  private readonly explicitSeedFile: string | undefined;
243
- private readonly openedFiles = new Set<string>();
275
+ private fallbackSeedFile: string | undefined;
276
+ private readonly maxOpenFiles: number;
277
+ private readonly maxFileBytes: number;
278
+ private readonly maxRefreshBytes: number;
279
+ private readonly maxFallbackSeedFiles: number;
280
+ private readonly openedFiles = new Map<string, { version: number; content: string }>();
244
281
  private readonly latestDiagnostics = new Map<string, Diagnostic[]>();
245
282
  private readonly diagnosticsWaiters = new Map<string, Array<() => void>>();
246
283
  private process: LanguageServerProcess | undefined;
247
284
  private initializing: Promise<LanguageServerProcess> | undefined;
248
285
 
249
- constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string) {
286
+ constructor(cwd: string, descriptor: LanguageServerDescriptor, seedFile?: string, options: LspSymbolIndexOptions = {}) {
250
287
  this.cwd = cwd;
251
288
  this.descriptor = descriptor;
252
289
  this.explicitSeedFile = seedFile;
290
+ this.maxOpenFiles = positiveLimit(options.maxOpenFiles, DEFAULT_MAX_OPEN_FILES, "maxOpenFiles");
291
+ this.maxFileBytes = positiveLimit(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, "maxFileBytes");
292
+ this.maxRefreshBytes = positiveLimit(options.maxRefreshBytes, 50 * 1024 * 1024, "maxRefreshBytes");
293
+ this.maxFallbackSeedFiles = positiveLimit(options.maxFallbackSeedFiles, 8, "maxFallbackSeedFiles");
294
+ const settleMs = descriptor.settleMs ?? DEFAULT_SETTLE_MS;
295
+ if (!Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > MAX_SETTLE_MS)
296
+ throw new TypeError(`settleMs must be an integer from 0 through ${MAX_SETTLE_MS}`);
297
+ this.provenance = {
298
+ fidelity: "semantic",
299
+ backend: descriptor.backendId,
300
+ languageId: descriptor.languageId,
301
+ authority: "language-server",
302
+ freshness: "live-process",
303
+ limitations: [],
304
+ };
253
305
  }
254
306
 
255
307
  /** Undefined before the server process has been spawned (first real query). */
@@ -260,11 +312,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
260
312
  private async ensureInitialized(): Promise<LanguageServerProcess> {
261
313
  if (this.process) return this.process;
262
314
  if (!this.initializing) {
315
+ let spawned: LanguageServerProcess | undefined;
263
316
  this.initializing = (async () => {
264
317
  const proc = LanguageServerProcess.spawnProcess({
265
318
  ...resolveLanguageServerCommand(this.descriptor),
266
319
  cwd: this.cwd,
267
320
  });
321
+ spawned = proc;
268
322
  proc.onNotification("textDocument/publishDiagnostics", (params) => {
269
323
  const { uri, diagnostics } = params as LspPublishDiagnosticsParams;
270
324
  const path = fileURLToPath(uri);
@@ -296,24 +350,13 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
296
350
  });
297
351
  proc.notify("initialized", {});
298
352
 
299
- const seedFile = this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
300
- const seedPath = join(this.cwd, seedFile);
301
- proc.notify("textDocument/didOpen", {
302
- textDocument: {
303
- uri: pathToFileURL(seedPath).href,
304
- languageId: this.descriptor.languageId,
305
- version: 1,
306
- text: readFileSync(seedPath, "utf-8"),
307
- },
308
- });
309
- this.openedFiles.add(seedPath);
310
- // No server signals "project loaded"; must match ensureFileOpen's wait below,
311
- // since the seed file is often the first file a caller queries.
312
- await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
353
+ const seedFile = this.fallbackSeedFile ?? this.explicitSeedFile ?? resolveSeedFile(this.cwd, this.descriptor);
354
+ await this.ensureFileOpen(proc, join(this.cwd, seedFile));
313
355
 
314
356
  this.process = proc;
315
357
  return proc;
316
- })().catch((error: unknown) => {
358
+ })().catch(async (error: unknown) => {
359
+ await spawned?.stop();
317
360
  // A failed initialize must not permanently poison this workspace's index --
318
361
  // the next call retries fresh rather than replaying the same rejection forever.
319
362
  this.initializing = undefined;
@@ -323,13 +366,37 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
323
366
  return this.initializing;
324
367
  }
325
368
 
326
- /** Opens `path` with the server if not already open, then waits for it to settle. A no-op past the first call for a given path. */
369
+ private readBoundedFile(path: string): string {
370
+ const content = readFileSync(path);
371
+ if (content.byteLength > this.maxFileBytes) throw new LanguageFileLimitExceeded("file-bytes", this.maxFileBytes, content.byteLength);
372
+ return content.toString("utf-8");
373
+ }
374
+
375
+ /** Opens a file or sends a monotonic full-document change when its disk content changed since the prior query. */
327
376
  private async ensureFileOpen(proc: LanguageServerProcess, path: string): Promise<void> {
328
- if (this.openedFiles.has(path)) return;
329
- proc.notify("textDocument/didOpen", {
330
- textDocument: { uri: pathToFileURL(path).href, languageId: this.descriptor.languageId, version: 1, text: readFileSync(path, "utf-8") },
331
- });
332
- this.openedFiles.add(path);
377
+ const content = this.readBoundedFile(path);
378
+ const opened = this.openedFiles.get(path);
379
+ if (opened?.content === content) return;
380
+ if (!opened) {
381
+ if (this.openedFiles.size >= this.maxOpenFiles) throw new LanguageFileLimitExceeded("open-files", this.maxOpenFiles, this.openedFiles.size + 1);
382
+ proc.notify("textDocument/didOpen", {
383
+ textDocument: {
384
+ uri: pathToFileURL(path).href,
385
+ languageId: this.descriptor.documentLanguageIds?.[extname(path)] ?? this.descriptor.languageId,
386
+ version: 1,
387
+ text: content,
388
+ },
389
+ });
390
+ this.openedFiles.set(path, { version: 1, content });
391
+ } else {
392
+ const version = opened.version + 1;
393
+ this.latestDiagnostics.delete(path);
394
+ proc.notify("textDocument/didChange", {
395
+ textDocument: { uri: pathToFileURL(path).href, version },
396
+ contentChanges: [{ text: content }],
397
+ });
398
+ this.openedFiles.set(path, { version, content });
399
+ }
333
400
  await new Promise((resolve) => setTimeout(resolve, this.descriptor.settleMs ?? DEFAULT_SETTLE_MS));
334
401
  }
335
402
 
@@ -346,10 +413,39 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
346
413
  });
347
414
  }
348
415
 
349
- async findSymbols(query: string): Promise<WorkspaceSymbol[]> {
350
- const proc = await this.ensureInitialized();
351
- const results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
352
- return results.map((symbol) => ({
416
+ private async restartForSeed(seedFile: string): Promise<LanguageServerProcess> {
417
+ await this.process?.stop();
418
+ this.process = undefined;
419
+ this.initializing = undefined;
420
+ this.openedFiles.clear();
421
+ this.latestDiagnostics.clear();
422
+ this.diagnosticsWaiters.clear();
423
+ this.fallbackSeedFile = seedFile;
424
+ return this.ensureInitialized();
425
+ }
426
+
427
+ async findSymbols(query: string, bounds: SymbolSearchBounds = { maxResults: DEFAULT_MAX_SYMBOL_RESULTS }): Promise<SymbolSearchResult> {
428
+ if (!Number.isSafeInteger(bounds.maxResults) || bounds.maxResults < 1) throw new TypeError("maxResults must be a positive safe integer");
429
+ let proc = await this.ensureInitialized();
430
+ let refreshedBytes = 0;
431
+ for (const path of this.openedFiles.keys()) {
432
+ await this.ensureFileOpen(proc, path);
433
+ refreshedBytes += Buffer.byteLength(this.openedFiles.get(path)?.content ?? "", "utf-8");
434
+ if (refreshedBytes > this.maxRefreshBytes) throw new LanguageFileLimitExceeded("refresh-bytes", this.maxRefreshBytes, refreshedBytes);
435
+ }
436
+ let results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
437
+ if (results.length === 0 && this.descriptor.languageId === "typescript") {
438
+ const candidates = await new TypeScriptCompilerSymbolIndex(this.cwd, { maxResults: this.maxFallbackSeedFiles }).findSymbols(query, {
439
+ maxResults: this.maxFallbackSeedFiles,
440
+ });
441
+ const candidate = candidates.symbols[0];
442
+ if (candidate) {
443
+ proc = await this.restartForSeed(candidate.location.path);
444
+ results = (await proc.request<LspSymbolInformation[] | null>("workspace/symbol", { query })) ?? [];
445
+ }
446
+ }
447
+ const truncated = results.length > bounds.maxResults;
448
+ const symbols: WorkspaceSymbol[] = results.slice(0, bounds.maxResults).map((symbol) => ({
353
449
  name: symbol.name,
354
450
  kind: LSP_SYMBOL_KIND_NAMES[symbol.kind] ?? "unknown",
355
451
  location: {
@@ -359,6 +455,7 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
359
455
  },
360
456
  containerName: symbol.containerName,
361
457
  }));
458
+ return { symbols, truncated, provenance: this.provenance };
362
459
  }
363
460
 
364
461
  async goToDefinition(at: WorkspaceLocation): Promise<WorkspaceLocation[]> {
@@ -462,7 +559,9 @@ export class LspSymbolIndex implements SymbolIndexPort, CodeIntelligencePort {
462
559
  }
463
560
 
464
561
  async close(): Promise<void> {
465
- await this.process?.stop();
562
+ const initializing = this.initializing;
563
+ const process = this.process ?? (initializing ? await initializing.catch(() => undefined) : undefined);
564
+ await process?.stop();
466
565
  this.process = undefined;
467
566
  this.initializing = undefined;
468
567
  this.openedFiles.clear();
@@ -0,0 +1,77 @@
1
+ import { posix } from "node:path";
2
+ import type { NpmRepositoryMetadata } from "../domain/npm-package-metadata.ts";
3
+ import type { RepoReference } from "../domain/repo-reference.ts";
4
+
5
+ export interface NormalizedNpmRepository {
6
+ readonly url: string;
7
+ readonly host: string;
8
+ readonly owner: string;
9
+ readonly repo: string;
10
+ readonly directory: string | null;
11
+ }
12
+
13
+ function containsControlCharacter(value: string): boolean {
14
+ for (let index = 0; index < value.length; index++) {
15
+ const code = value.charCodeAt(index);
16
+ if (code <= 31 || code === 127) return true;
17
+ }
18
+ return false;
19
+ }
20
+
21
+ function normalizedDirectory(raw: string | null): string | null {
22
+ if (raw === null) return null;
23
+ if (raw.length === 0 || raw.length > 2048 || raw.includes("\\") || containsControlCharacter(raw) || posix.isAbsolute(raw)) return null;
24
+ const normalized = posix.normalize(raw.replace(/^\.\//, "")).replace(/\/$/, "");
25
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.length === 0) return null;
26
+ return normalized;
27
+ }
28
+
29
+ function shorthand(raw: string): string {
30
+ for (const [prefix, host] of [
31
+ ["github:", "github.com"],
32
+ ["gitlab:", "gitlab.com"],
33
+ ["bitbucket:", "bitbucket.org"],
34
+ ] as const) {
35
+ if (raw.startsWith(prefix)) return `https://${host}/${raw.slice(prefix.length)}`;
36
+ }
37
+ if (/^[^/:\s]+\/[^/\s]+(?:\.git)?$/.test(raw)) return `https://github.com/${raw}`;
38
+ return raw;
39
+ }
40
+
41
+ function repositoryUrl(raw: string): URL | null {
42
+ let candidate = shorthand(raw.trim()).replace(/^git\+/, "");
43
+ const scp = candidate.match(/^git@([^:]+):(.+)$/);
44
+ if (scp) candidate = `ssh://git@${scp[1]}/${scp[2]}`;
45
+ if (candidate.startsWith("git://")) candidate = `https://${candidate.slice("git://".length)}`;
46
+ let parsed: URL;
47
+ try {
48
+ parsed = new URL(candidate);
49
+ } catch {
50
+ return null;
51
+ }
52
+ if (!["https:", "http:", "ssh:"].includes(parsed.protocol) || parsed.search || parsed.hash || parsed.password) return null;
53
+ if (parsed.username && parsed.username !== "git") return null;
54
+ return parsed;
55
+ }
56
+
57
+ export function normalizeNpmRepository(metadata: NpmRepositoryMetadata): NormalizedNpmRepository | null {
58
+ if (metadata.type !== null && metadata.type.toLowerCase() !== "git") return null;
59
+ const parsed = repositoryUrl(metadata.url);
60
+ if (parsed === null) return null;
61
+ const segments = parsed.pathname
62
+ .replace(/^\//, "")
63
+ .replace(/\.git$/, "")
64
+ .split("/")
65
+ .filter(Boolean);
66
+ if (segments.length !== 2) return null;
67
+ const [owner, repo] = segments;
68
+ if (!owner || !repo || owner === "." || owner === ".." || repo === "." || repo === "..") return null;
69
+ const directory = normalizedDirectory(metadata.directory);
70
+ if (metadata.directory !== null && directory === null) return null;
71
+ const host = parsed.hostname.toLowerCase();
72
+ return { url: `https://${host}/${owner}/${repo}.git`, host, owner, repo, directory };
73
+ }
74
+
75
+ export function npmRepositoryReference(repository: NormalizedNpmRepository, ref: string): RepoReference {
76
+ return { host: repository.host, owner: repository.owner, repo: repository.repo, ref };
77
+ }