@danypops/lector 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/package.json +48 -0
  4. package/src/adapters/directory-size.ts +19 -0
  5. package/src/adapters/find-source-files.ts +35 -0
  6. package/src/adapters/git-repo-fetcher.ts +146 -0
  7. package/src/adapters/in-memory-content-cache.ts +19 -0
  8. package/src/adapters/in-memory-search-cache.ts +32 -0
  9. package/src/adapters/in-memory-symbol-graph.ts +80 -0
  10. package/src/adapters/in-memory-workspace.ts +28 -0
  11. package/src/adapters/local-filesystem-workspace.ts +96 -0
  12. package/src/adapters/local-git.ts +73 -0
  13. package/src/adapters/lsp/discover-seed-file.ts +130 -0
  14. package/src/adapters/lsp/json-rpc-stream.ts +67 -0
  15. package/src/adapters/lsp/language-server-process.ts +184 -0
  16. package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
  17. package/src/adapters/lsp/process-resource-usage.ts +61 -0
  18. package/src/adapters/lsp/typescript-project-files.ts +51 -0
  19. package/src/adapters/read-only-workspace.ts +23 -0
  20. package/src/adapters/ripgrep-text-search.ts +97 -0
  21. package/src/adapters/source-manifest.ts +44 -0
  22. package/src/adapters/sqlite-content-cache.ts +67 -0
  23. package/src/adapters/sqlite-search-cache.ts +60 -0
  24. package/src/adapters/sqlite-symbol-graph.ts +154 -0
  25. package/src/adapters/tiered-search-cache.ts +29 -0
  26. package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
  27. package/src/cli.ts +777 -0
  28. package/src/client.ts +63 -0
  29. package/src/constants.ts +16 -0
  30. package/src/daemon.ts +166 -0
  31. package/src/domain/assert-safe-git-argument.ts +19 -0
  32. package/src/domain/assert-safe-path-segment.ts +17 -0
  33. package/src/domain/assert-safe-repo-reference.ts +20 -0
  34. package/src/domain/assert-safe-search-query.ts +17 -0
  35. package/src/domain/bounded-job-executor.ts +219 -0
  36. package/src/domain/call-hierarchy.ts +23 -0
  37. package/src/domain/code-range.ts +11 -0
  38. package/src/domain/content-hash.ts +14 -0
  39. package/src/domain/diagnostic.ts +12 -0
  40. package/src/domain/diagnostics.ts +7 -0
  41. package/src/domain/document-symbol.ts +19 -0
  42. package/src/domain/document-symbols.ts +7 -0
  43. package/src/domain/exact-edit.ts +43 -0
  44. package/src/domain/find-references.ts +7 -0
  45. package/src/domain/find-workspace-symbols.ts +7 -0
  46. package/src/domain/git-diff-result.ts +5 -0
  47. package/src/domain/git-log-entry.ts +9 -0
  48. package/src/domain/git-status.ts +17 -0
  49. package/src/domain/go-to-definition.ts +7 -0
  50. package/src/domain/go-to-implementation.ts +7 -0
  51. package/src/domain/hover-at.ts +8 -0
  52. package/src/domain/hover.ts +7 -0
  53. package/src/domain/incoming-calls.ts +8 -0
  54. package/src/domain/language-server-descriptor.ts +122 -0
  55. package/src/domain/outgoing-calls.ts +8 -0
  56. package/src/domain/populate-symbol-graph.ts +91 -0
  57. package/src/domain/prepare-call-hierarchy.ts +8 -0
  58. package/src/domain/race-workspace-query.ts +39 -0
  59. package/src/domain/raw-read.ts +24 -0
  60. package/src/domain/reachable-symbols-from.ts +13 -0
  61. package/src/domain/repo-fetch-result.ts +18 -0
  62. package/src/domain/repo-reference.ts +7 -0
  63. package/src/domain/search-cache-key.ts +16 -0
  64. package/src/domain/search-text.ts +29 -0
  65. package/src/domain/symbol-edges-from.ts +9 -0
  66. package/src/domain/symbol-edges-to.ts +9 -0
  67. package/src/domain/symbol-graph-generation.ts +14 -0
  68. package/src/domain/symbol-node-id.ts +13 -0
  69. package/src/domain/text-search-result.ts +15 -0
  70. package/src/domain/workspace-query-outcome.ts +24 -0
  71. package/src/domain/workspace-symbol.ts +14 -0
  72. package/src/index.ts +121 -0
  73. package/src/ports/code-intelligence-port.ts +38 -0
  74. package/src/ports/content-cache-port.ts +52 -0
  75. package/src/ports/git-port.ts +23 -0
  76. package/src/ports/repo-fetcher-port.ts +11 -0
  77. package/src/ports/search-cache-port.ts +15 -0
  78. package/src/ports/symbol-graph-port.ts +35 -0
  79. package/src/ports/symbol-index-port.ts +11 -0
  80. package/src/ports/text-search-port.ts +12 -0
  81. package/src/ports/workspace-port.ts +35 -0
  82. package/src/service.ts +961 -0
  83. package/src/version.ts +6 -0
@@ -0,0 +1,130 @@
1
+ import { type Dirent, existsSync, readdirSync } from "node:fs";
2
+ import { extname, join, sep } from "node:path";
3
+ import type { LanguageServerDescriptor } from "../../domain/language-server-descriptor.ts";
4
+ import { refineTypescriptSeedFile } from "./typescript-project-files.ts";
5
+
6
+ const SKIP_DIRECTORY_NAMES = new Set(["node_modules", "dist", "build", "out", "coverage"]);
7
+ const MAX_SCAN_DEPTH = 4;
8
+ const MAX_ENTRIES_SCANNED = 2_000;
9
+
10
+ /** Raised when no matching source file could be found to warm a language server with. */
11
+ export class NoSeedFileFound extends Error {
12
+ constructor(
13
+ readonly rootPath: string,
14
+ readonly extensions: readonly string[],
15
+ ) {
16
+ super(`no source file matching [${extensions.join(", ")}] found under "${rootPath}" to warm the language server with`);
17
+ this.name = "NoSeedFileFound";
18
+ }
19
+ }
20
+
21
+ /**
22
+ * True when `relativeFilePath` has `marker` in its own directory or any ancestor directory up
23
+ * to and including rootPath -- i.e. whether a language server would actually associate this
24
+ * file with a real, config-backed project, as opposed to treating it as an orphan file with no
25
+ * project coverage (a standalone root-level *.config.ts next to a monorepo root that has no
26
+ * root tsconfig.json is exactly this case: real extension match, zero project coverage).
27
+ */
28
+ function hasMarkerAncestor(rootPath: string, relativeFilePath: string, marker: string): boolean {
29
+ const parts = relativeFilePath.split(sep);
30
+ parts.pop();
31
+ for (let i = parts.length; i >= 0; i--) {
32
+ if (existsSync(join(rootPath, ...parts.slice(0, i), marker))) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Pick a workspace-relative file to open first so a language server has a live project to
39
+ * search (see LspSymbolIndex's own doc comment on the "No Project." gotcha). A caller
40
+ * should never have to know or care which file this is -- it is a pure implementation
41
+ * detail of warming the language server, not part of the workspace's identity or the
42
+ * query's meaning.
43
+ *
44
+ * Tries `commonCandidates` first (e.g. a language's usual entry-point names); falls back to a
45
+ * bounded (depth- and entry-count-limited, per "Bound resources and outputs explicitly")
46
+ * directory scan matching `extensions`, deterministic (alphabetically sorted). Among files found
47
+ * during that scan, prefers one with `rootMarkers[0]` (e.g. tsconfig.json) among its ancestors --
48
+ * a real, previously-undetected bug (confirmed against this project's own monorepo root) let a
49
+ * standalone root-level eslint.config.ts win purely by alphabetical luck over any real project
50
+ * file, silently limiting workspace/symbol to a project that doesn't include the actual source
51
+ * tree at all. Only falls back to the first alphabetical match when nothing in the scanned
52
+ * subtree has real project coverage.
53
+ */
54
+ export function discoverSeedFile(
55
+ rootPath: string,
56
+ extensions: readonly string[],
57
+ commonCandidates: readonly string[],
58
+ rootMarkers: readonly string[] = [],
59
+ ): string {
60
+ for (const candidate of commonCandidates) {
61
+ if (existsSync(join(rootPath, candidate))) return candidate;
62
+ }
63
+ const sourceExtensions = new Set(extensions);
64
+ const primaryMarker = rootMarkers[0];
65
+ let fallback: string | undefined;
66
+
67
+ let scanned = 0;
68
+ const visit = (relativeDir: string, depth: number): string | undefined => {
69
+ if (depth > MAX_SCAN_DEPTH) return undefined;
70
+ let entries: Dirent[];
71
+ try {
72
+ entries = readdirSync(join(rootPath, relativeDir), { withFileTypes: true, encoding: "utf-8" });
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
77
+
78
+ for (const entry of sorted) {
79
+ if (scanned >= MAX_ENTRIES_SCANNED) return fallback;
80
+ scanned++;
81
+ const relativePath = relativeDir ? join(relativeDir, entry.name) : entry.name;
82
+ if (entry.isDirectory()) {
83
+ if (SKIP_DIRECTORY_NAMES.has(entry.name) || entry.name.startsWith(".")) continue;
84
+ const found = visit(relativePath, depth + 1);
85
+ if (found) return found;
86
+ } else if (entry.isFile() && sourceExtensions.has(extname(entry.name))) {
87
+ if (!primaryMarker || hasMarkerAncestor(rootPath, relativePath, primaryMarker)) return relativePath;
88
+ fallback ??= relativePath;
89
+ }
90
+ }
91
+ return undefined;
92
+ };
93
+
94
+ const found = visit("", 0) ?? fallback;
95
+ if (!found) throw new NoSeedFileFound(rootPath, extensions);
96
+ return found;
97
+ }
98
+
99
+ /**
100
+ * discoverSeedFile plus a TypeScript-specific refinement pass -- the generic ancestor-marker
101
+ * heuristic isn't sufficient for TypeScript specifically: a real tsconfig.json ancestor doesn't
102
+ * mean that tsconfig's own include/exclude actually covers the candidate (confirmed empirically
103
+ * against this project's own monorepo, see typescript-project-files.ts). Kept as an explicit,
104
+ * named special case here rather than a generic hook on LanguageServerDescriptor -- only one
105
+ * language currently needs it, and forcing every descriptor to carry an unused hook field would
106
+ * be speculative generality this project's own conventions argue against.
107
+ */
108
+ export function resolveSeedFile(rootPath: string, descriptor: LanguageServerDescriptor): string {
109
+ const candidate = discoverSeedFile(rootPath, descriptor.extensions, descriptor.commonSeedCandidates, descriptor.rootMarkers);
110
+ return descriptor.languageId === "typescript" ? refineTypescriptSeedFile(rootPath, candidate) : candidate;
111
+ }
112
+
113
+ /**
114
+ * For workspace.findSymbols called with no seedFile -- no anchor file to pick a language from.
115
+ * Tries each descriptor's own discoverSeedFile in declared order; first real match wins.
116
+ */
117
+ export function discoverWorkspaceDescriptor(
118
+ rootPath: string,
119
+ descriptors: readonly LanguageServerDescriptor[],
120
+ ): { descriptor: LanguageServerDescriptor; seedFile: string } | undefined {
121
+ for (const descriptor of descriptors) {
122
+ try {
123
+ const seedFile = resolveSeedFile(rootPath, descriptor);
124
+ return { descriptor, seedFile };
125
+ } catch (error) {
126
+ if (!(error instanceof NoSeedFileFound)) throw error;
127
+ }
128
+ }
129
+ return undefined;
130
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Content-Length-framed JSON-RPC message encoding/decoding, per the LSP
3
+ * specification's base protocol. Pure and subprocess-free by design: this
4
+ * module never touches child_process, so it is independently testable
5
+ * against arbitrary byte-chunk boundaries without spawning anything.
6
+ */
7
+
8
+ export interface JsonRpcMessage {
9
+ readonly jsonrpc: "2.0";
10
+ readonly id?: number | string;
11
+ readonly method?: string;
12
+ readonly params?: unknown;
13
+ readonly result?: unknown;
14
+ readonly error?: { readonly code: number; readonly message: string; readonly data?: unknown };
15
+ }
16
+
17
+ export function encodeJsonRpcMessage(message: JsonRpcMessage): Buffer {
18
+ const body = Buffer.from(JSON.stringify(message), "utf-8");
19
+ const header = Buffer.from(`Content-Length: ${body.byteLength}\r\n\r\n`, "ascii");
20
+ return Buffer.concat([header, body]);
21
+ }
22
+
23
+ /**
24
+ * Incrementally parses Content-Length-framed messages from a byte stream
25
+ * that may deliver partial messages, multiple messages per chunk, or a
26
+ * header/body split across chunk boundaries -- exactly what a real child
27
+ * process's stdout does under backpressure or large payloads.
28
+ */
29
+ export class JsonRpcStreamDecoder {
30
+ private buffer: Buffer = Buffer.alloc(0);
31
+
32
+ /** Feed one chunk; returns every complete message it produced (zero or more, in order). */
33
+ push(chunk: Buffer): JsonRpcMessage[] {
34
+ this.buffer = Buffer.concat([this.buffer, chunk]);
35
+ const messages: JsonRpcMessage[] = [];
36
+
37
+ for (;;) {
38
+ const headerEnd = this.buffer.indexOf("\r\n\r\n");
39
+ if (headerEnd === -1) break; // header itself split across chunks -- wait for more
40
+
41
+ const header = this.buffer.subarray(0, headerEnd).toString("ascii");
42
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
43
+ const digits = match?.[1];
44
+ if (digits === undefined) {
45
+ // Malformed header we cannot recover a length from: drop through the
46
+ // terminator and keep going rather than getting stuck on it forever.
47
+ this.buffer = this.buffer.subarray(headerEnd + 4);
48
+ continue;
49
+ }
50
+
51
+ const length = Number.parseInt(digits, 10);
52
+ const bodyStart = headerEnd + 4;
53
+ const bodyEnd = bodyStart + length;
54
+ if (this.buffer.byteLength < bodyEnd) break; // body split across chunks -- wait for more
55
+
56
+ const body = this.buffer.subarray(bodyStart, bodyEnd).toString("utf-8");
57
+ this.buffer = this.buffer.subarray(bodyEnd);
58
+ try {
59
+ messages.push(JSON.parse(body) as JsonRpcMessage);
60
+ } catch {
61
+ // An unparseable body must not crash the whole stream -- skip it and continue.
62
+ }
63
+ }
64
+
65
+ return messages;
66
+ }
67
+ }
@@ -0,0 +1,184 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { encodeJsonRpcMessage, type JsonRpcMessage, JsonRpcStreamDecoder } from "./json-rpc-stream.ts";
3
+
4
+ export interface LanguageServerProcessOptions {
5
+ command: string;
6
+ args?: readonly string[];
7
+ cwd: string;
8
+ /** Extra environment variables merged over process.env for the spawned process. */
9
+ env?: Readonly<Record<string, string | undefined>>;
10
+ /** Per-request timeout. Default 10s. */
11
+ requestTimeoutMs?: number;
12
+ }
13
+
14
+ /** Raised when a request is sent (or was pending) after the server process already exited. */
15
+ export class LanguageServerProcessExited extends Error {
16
+ constructor(readonly command: string) {
17
+ super(`language server process "${command}" exited; the request cannot be fulfilled`);
18
+ this.name = "LanguageServerProcessExited";
19
+ }
20
+ }
21
+
22
+ /** Raised when a request does not receive a response within its timeout. */
23
+ export class LanguageServerRequestTimedOut extends Error {
24
+ constructor(
25
+ readonly method: string,
26
+ readonly timeoutMs: number,
27
+ ) {
28
+ super(`language server request "${method}" did not respond within ${timeoutMs}ms`);
29
+ this.name = "LanguageServerRequestTimedOut";
30
+ }
31
+ }
32
+
33
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
34
+ const DEFAULT_STOP_TIMEOUT_MS = 3_000;
35
+
36
+ interface PendingRequest {
37
+ resolve: (result: unknown) => void;
38
+ reject: (error: Error) => void;
39
+ timer: ReturnType<typeof setTimeout>;
40
+ }
41
+
42
+ type NotificationHandler = (params: unknown) => void;
43
+
44
+ /**
45
+ * A spawned language server subprocess with safe lifecycle management:
46
+ * spawned detached so stop() can kill its whole process group, not just
47
+ * the immediate child; a request made after the process has already
48
+ * exited is rejected immediately rather than left pending forever; and
49
+ * stop() force-kills the process group if a graceful shutdown doesn't
50
+ * complete in time.
51
+ */
52
+ export class LanguageServerProcess {
53
+ private readonly child: ChildProcessWithoutNullStreams;
54
+ private readonly decoder = new JsonRpcStreamDecoder();
55
+ private readonly pending = new Map<number, PendingRequest>();
56
+ private readonly notificationHandlers = new Map<string, Set<NotificationHandler>>();
57
+ private readonly requestTimeoutMs: number;
58
+ private nextId = 1;
59
+ private processExited = false;
60
+ private exitError: Error;
61
+
62
+ private constructor(child: ChildProcessWithoutNullStreams, label: string, requestTimeoutMs: number) {
63
+ this.child = child;
64
+ this.requestTimeoutMs = requestTimeoutMs;
65
+ this.exitError = new LanguageServerProcessExited(label);
66
+
67
+ this.child.stdout.on("data", (chunk: Buffer) => {
68
+ for (const message of this.decoder.push(chunk)) this.dispatch(message);
69
+ });
70
+
71
+ this.child.once("exit", () => {
72
+ this.processExited = true;
73
+ for (const [id, request] of this.pending) {
74
+ clearTimeout(request.timer);
75
+ request.reject(this.exitError);
76
+ this.pending.delete(id);
77
+ }
78
+ });
79
+ }
80
+
81
+ /** Undefined only if the OS never assigned one, i.e. spawn itself failed before this constructor ran. */
82
+ get pid(): number | undefined {
83
+ return this.child.pid;
84
+ }
85
+
86
+ static spawnProcess(options: LanguageServerProcessOptions): LanguageServerProcess {
87
+ const child = spawn(options.command, [...(options.args ?? [])], {
88
+ cwd: options.cwd,
89
+ env: { ...process.env, ...options.env },
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ // Own process group on POSIX so stop() can kill every descendant as a unit.
92
+ detached: process.platform !== "win32",
93
+ }) as ChildProcessWithoutNullStreams;
94
+ return new LanguageServerProcess(child, options.command, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
95
+ }
96
+
97
+ private dispatch(message: JsonRpcMessage): void {
98
+ if (typeof message.id === "number") {
99
+ const request = this.pending.get(message.id);
100
+ if (!request) return;
101
+ clearTimeout(request.timer);
102
+ this.pending.delete(message.id);
103
+ if (message.error) request.reject(new Error(message.error.message));
104
+ else request.resolve(message.result);
105
+ return;
106
+ }
107
+ // No numeric id: either a notification (no id at all) or a server-initiated request
108
+ // (its own id space, not this client's) -- only notifications are handled.
109
+ if (message.id !== undefined || !message.method) return;
110
+ for (const handler of this.notificationHandlers.get(message.method) ?? []) handler(message.params);
111
+ }
112
+
113
+ /** Subscribes to a server-pushed notification (e.g. textDocument/publishDiagnostics). Returns an unsubscribe function. */
114
+ onNotification(method: string, handler: NotificationHandler): () => void {
115
+ const handlers = this.notificationHandlers.get(method) ?? new Set();
116
+ handlers.add(handler);
117
+ this.notificationHandlers.set(method, handlers);
118
+ return () => handlers.delete(handler);
119
+ }
120
+
121
+ async request<T>(method: string, params: unknown): Promise<T> {
122
+ if (this.processExited) throw this.exitError;
123
+ const id = this.nextId++;
124
+ return new Promise<T>((resolve, reject) => {
125
+ const timer = setTimeout(() => {
126
+ this.pending.delete(id);
127
+ reject(new LanguageServerRequestTimedOut(method, this.requestTimeoutMs));
128
+ }, this.requestTimeoutMs);
129
+ this.pending.set(id, { resolve: resolve as (result: unknown) => void, reject, timer });
130
+ this.child.stdin.write(encodeJsonRpcMessage({ jsonrpc: "2.0", id, method, params }));
131
+ });
132
+ }
133
+
134
+ notify(method: string, params: unknown): void {
135
+ if (this.processExited) return;
136
+ this.child.stdin.write(encodeJsonRpcMessage({ jsonrpc: "2.0", method, params }));
137
+ }
138
+
139
+ /**
140
+ * Attempts a graceful `shutdown` request followed by an `exit` notification,
141
+ * bounded by `stopTimeoutMs`. If the process has not exited by then -- the
142
+ * graceful path hung, errored, or the process simply ignored it -- kills
143
+ * the entire process group so no descendant is left running.
144
+ */
145
+ async stop(stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<void> {
146
+ if (this.processExited) return;
147
+
148
+ const exited = new Promise<void>((resolve) => {
149
+ if (this.processExited) resolve();
150
+ else this.child.once("exit", () => resolve());
151
+ });
152
+
153
+ await Promise.race([
154
+ (async () => {
155
+ try {
156
+ await this.request("shutdown", null);
157
+ this.notify("exit", null);
158
+ } catch {
159
+ // Falls through to the hard kill below regardless of why the graceful
160
+ // path failed (timeout, process already gone, protocol error, ...).
161
+ }
162
+ })(),
163
+ new Promise<void>((resolve) => setTimeout(resolve, stopTimeoutMs)),
164
+ ]);
165
+
166
+ if (!this.processExited) this.killProcessGroup();
167
+
168
+ // Bounded wait for the exit event to actually land; stop() must itself never hang
169
+ // even if, somehow, neither the graceful path nor SIGKILL produced one promptly.
170
+ await Promise.race([exited, new Promise<void>((resolve) => setTimeout(resolve, stopTimeoutMs))]);
171
+ }
172
+
173
+ private killProcessGroup(): void {
174
+ try {
175
+ if (process.platform !== "win32" && this.child.pid) {
176
+ process.kill(-this.child.pid, "SIGKILL"); // negative pid == the whole process group
177
+ } else {
178
+ this.child.kill("SIGKILL");
179
+ }
180
+ } catch {
181
+ // ESRCH etc. -- already gone.
182
+ }
183
+ }
184
+ }