@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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +48 -0
- package/src/adapters/directory-size.ts +19 -0
- package/src/adapters/find-source-files.ts +35 -0
- package/src/adapters/git-repo-fetcher.ts +146 -0
- package/src/adapters/in-memory-content-cache.ts +19 -0
- package/src/adapters/in-memory-search-cache.ts +32 -0
- package/src/adapters/in-memory-symbol-graph.ts +80 -0
- package/src/adapters/in-memory-workspace.ts +28 -0
- package/src/adapters/local-filesystem-workspace.ts +96 -0
- package/src/adapters/local-git.ts +73 -0
- package/src/adapters/lsp/discover-seed-file.ts +130 -0
- package/src/adapters/lsp/json-rpc-stream.ts +67 -0
- package/src/adapters/lsp/language-server-process.ts +184 -0
- package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
- package/src/adapters/lsp/process-resource-usage.ts +61 -0
- package/src/adapters/lsp/typescript-project-files.ts +51 -0
- package/src/adapters/read-only-workspace.ts +23 -0
- package/src/adapters/ripgrep-text-search.ts +97 -0
- package/src/adapters/source-manifest.ts +44 -0
- package/src/adapters/sqlite-content-cache.ts +67 -0
- package/src/adapters/sqlite-search-cache.ts +60 -0
- package/src/adapters/sqlite-symbol-graph.ts +154 -0
- package/src/adapters/tiered-search-cache.ts +29 -0
- package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
- package/src/cli.ts +777 -0
- package/src/client.ts +63 -0
- package/src/constants.ts +16 -0
- package/src/daemon.ts +166 -0
- package/src/domain/assert-safe-git-argument.ts +19 -0
- package/src/domain/assert-safe-path-segment.ts +17 -0
- package/src/domain/assert-safe-repo-reference.ts +20 -0
- package/src/domain/assert-safe-search-query.ts +17 -0
- package/src/domain/bounded-job-executor.ts +219 -0
- package/src/domain/call-hierarchy.ts +23 -0
- package/src/domain/code-range.ts +11 -0
- package/src/domain/content-hash.ts +14 -0
- package/src/domain/diagnostic.ts +12 -0
- package/src/domain/diagnostics.ts +7 -0
- package/src/domain/document-symbol.ts +19 -0
- package/src/domain/document-symbols.ts +7 -0
- package/src/domain/exact-edit.ts +43 -0
- package/src/domain/find-references.ts +7 -0
- package/src/domain/find-workspace-symbols.ts +7 -0
- package/src/domain/git-diff-result.ts +5 -0
- package/src/domain/git-log-entry.ts +9 -0
- package/src/domain/git-status.ts +17 -0
- package/src/domain/go-to-definition.ts +7 -0
- package/src/domain/go-to-implementation.ts +7 -0
- package/src/domain/hover-at.ts +8 -0
- package/src/domain/hover.ts +7 -0
- package/src/domain/incoming-calls.ts +8 -0
- package/src/domain/language-server-descriptor.ts +122 -0
- package/src/domain/outgoing-calls.ts +8 -0
- package/src/domain/populate-symbol-graph.ts +91 -0
- package/src/domain/prepare-call-hierarchy.ts +8 -0
- package/src/domain/race-workspace-query.ts +39 -0
- package/src/domain/raw-read.ts +24 -0
- package/src/domain/reachable-symbols-from.ts +13 -0
- package/src/domain/repo-fetch-result.ts +18 -0
- package/src/domain/repo-reference.ts +7 -0
- package/src/domain/search-cache-key.ts +16 -0
- package/src/domain/search-text.ts +29 -0
- package/src/domain/symbol-edges-from.ts +9 -0
- package/src/domain/symbol-edges-to.ts +9 -0
- package/src/domain/symbol-graph-generation.ts +14 -0
- package/src/domain/symbol-node-id.ts +13 -0
- package/src/domain/text-search-result.ts +15 -0
- package/src/domain/workspace-query-outcome.ts +24 -0
- package/src/domain/workspace-symbol.ts +14 -0
- package/src/index.ts +121 -0
- package/src/ports/code-intelligence-port.ts +38 -0
- package/src/ports/content-cache-port.ts +52 -0
- package/src/ports/git-port.ts +23 -0
- package/src/ports/repo-fetcher-port.ts +11 -0
- package/src/ports/search-cache-port.ts +15 -0
- package/src/ports/symbol-graph-port.ts +35 -0
- package/src/ports/symbol-index-port.ts +11 -0
- package/src/ports/text-search-port.ts +12 -0
- package/src/ports/workspace-port.ts +35 -0
- package/src/service.ts +961 -0
- package/src/version.ts +6 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { type DaemonPaths, readDaemonHandle } from "@danypops/daemon-kit/paths";
|
|
3
|
+
import { AuthenticatedRpcClient } from "@danypops/daemon-kit/rpc-client";
|
|
4
|
+
import { resolveLectorPaths } from "./constants.ts";
|
|
5
|
+
import type { OperationInputs, OperationName, OperationOutputs } from "./service.ts";
|
|
6
|
+
|
|
7
|
+
export type LectorClient = AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>;
|
|
8
|
+
|
|
9
|
+
export interface ConnectLectorClientOptions {
|
|
10
|
+
/** Override resolved paths (tests inject an isolated tmp root). Defaults to the real XDG paths. */
|
|
11
|
+
paths?: DaemonPaths;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Connect to a running Lector daemon, probing health before returning. */
|
|
15
|
+
export async function connectLectorClient(options: ConnectLectorClientOptions = {}): Promise<LectorClient> {
|
|
16
|
+
const paths = options.paths ?? resolveLectorPaths();
|
|
17
|
+
const handle = readDaemonHandle(paths.handle);
|
|
18
|
+
if (!handle) throw new Error("Lector daemon is not running; start it with `lector serve`");
|
|
19
|
+
|
|
20
|
+
let token: string;
|
|
21
|
+
try {
|
|
22
|
+
token = readFileSync(paths.token, "utf8").trim();
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error("Lector daemon token is unreadable; restart it with `lector serve`");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>(`http://${handle.host}:${handle.port}`, token, {
|
|
28
|
+
label: "Lector",
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
await client.health();
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error("Lector daemon state is stale or unreachable; restart it with `lector serve`");
|
|
34
|
+
}
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Construct a LectorClient directly against a known host/port/token, without
|
|
40
|
+
* going through paths/handle-file discovery. For callers (tests, host
|
|
41
|
+
* adapters standing up their own isolated daemon) that already have a
|
|
42
|
+
* RunningDaemon and its token in hand. Ensures every consumer of Lector's
|
|
43
|
+
* client gets the exact same AuthenticatedRpcClient class instance Lector
|
|
44
|
+
* itself depends on -- a second, independently-resolved copy of
|
|
45
|
+
* @danypops/daemon-kit in a consumer's own node_modules would otherwise be a
|
|
46
|
+
* structurally distinct (if identical-looking) type.
|
|
47
|
+
*/
|
|
48
|
+
export function connectLectorClientAt(baseUrl: string, token: string): LectorClient {
|
|
49
|
+
return new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>(baseUrl, token, { label: "Lector" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when `error` is the client-side rejection of a call whose Lector
|
|
54
|
+
* domain error was `name` (e.g. "StaleExpectedHash"). The RPC transport
|
|
55
|
+
* carries only a single error string -- see daemon.ts's ops-endpoint catch
|
|
56
|
+
* block -- so a domain error class does not survive `instanceof` across
|
|
57
|
+
* HTTP; every Lector domain error's `.name` is rendered as a `"<name>: "`
|
|
58
|
+
* prefix on that string instead, and this is the one place that convention
|
|
59
|
+
* should be read back, so host adapters never hand-roll message parsing.
|
|
60
|
+
*/
|
|
61
|
+
export function remoteErrorIs(error: unknown, name: string): boolean {
|
|
62
|
+
return error instanceof Error && error.message.startsWith(`${name}: `);
|
|
63
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DaemonPathNames, type DaemonPaths, type PathEnvironment, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
|
|
2
|
+
|
|
3
|
+
export const DAEMON_LABEL = "Lector";
|
|
4
|
+
|
|
5
|
+
export const LECTOR_PATH_NAMES: DaemonPathNames = {
|
|
6
|
+
stateDirectoryName: "lector",
|
|
7
|
+
databaseFilename: "lector.db",
|
|
8
|
+
tokenFilename: "token",
|
|
9
|
+
handleFilename: "handle.json",
|
|
10
|
+
systemdUnitName: "lector.service",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Resolve Lector's XDG-compliant paths. Tests inject `environment` to isolate against a tmp root. */
|
|
14
|
+
export function resolveLectorPaths(environment?: PathEnvironment): DaemonPaths {
|
|
15
|
+
return resolveDaemonPaths(LECTOR_PATH_NAMES, environment);
|
|
16
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { type MaintenanceTask, type RunningDaemon, runDaemonProcess, startDaemon } from "@danypops/daemon-kit/daemon";
|
|
3
|
+
import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/daemon-kit/http";
|
|
4
|
+
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
5
|
+
import { type DaemonPaths, ensureAuthToken } from "@danypops/daemon-kit/paths";
|
|
6
|
+
import { GitRepoFetcher } from "./adapters/git-repo-fetcher.ts";
|
|
7
|
+
import { InMemorySearchCache } from "./adapters/in-memory-search-cache.ts";
|
|
8
|
+
import { SqliteSearchCache } from "./adapters/sqlite-search-cache.ts";
|
|
9
|
+
import { SqliteSymbolGraph } from "./adapters/sqlite-symbol-graph.ts";
|
|
10
|
+
import { TieredSearchCache } from "./adapters/tiered-search-cache.ts";
|
|
11
|
+
import { resolveLectorPaths } from "./constants.ts";
|
|
12
|
+
import type { RepoFetcherPort } from "./ports/repo-fetcher-port.ts";
|
|
13
|
+
import type { WorkspacePort } from "./ports/workspace-port.ts";
|
|
14
|
+
import { createLectorService, type LectorService, type OperationName, type WorkspaceId } from "./service.ts";
|
|
15
|
+
import { lectorVersion } from "./version.ts";
|
|
16
|
+
|
|
17
|
+
/** The Lector daemon's HTTP surface: Bearer-auth, health/ready, and the ops dispatch endpoint. */
|
|
18
|
+
export function buildLectorApp(service: LectorService, token: string): { fetch(request: Request): Promise<Response> } {
|
|
19
|
+
return {
|
|
20
|
+
async fetch(request: Request): Promise<Response> {
|
|
21
|
+
if (!requireBearerToken(request, token)) return errorResponse("unauthorized", 401);
|
|
22
|
+
const url = new URL(request.url);
|
|
23
|
+
|
|
24
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
25
|
+
return healthResponse(lectorVersion());
|
|
26
|
+
}
|
|
27
|
+
if (request.method === "GET" && url.pathname === "/ready") {
|
|
28
|
+
return readyResponse(true);
|
|
29
|
+
}
|
|
30
|
+
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
31
|
+
return jsonResponse({ operations: service.operations });
|
|
32
|
+
}
|
|
33
|
+
if (request.method === "POST" && url.pathname === "/api/v1/ops") {
|
|
34
|
+
let body: { op?: unknown; input?: unknown };
|
|
35
|
+
try {
|
|
36
|
+
body = (await request.json()) as { op?: unknown; input?: unknown };
|
|
37
|
+
} catch {
|
|
38
|
+
return errorResponse("invalid JSON body", 400);
|
|
39
|
+
}
|
|
40
|
+
if (typeof body.op !== "string" || !service.operations.includes(body.op as OperationName)) {
|
|
41
|
+
return errorResponse(`unknown operation: ${String(body.op)}`, 400);
|
|
42
|
+
}
|
|
43
|
+
if (typeof body.input !== "object" || body.input === null) {
|
|
44
|
+
return errorResponse("input must be an object", 400);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const result = await service.dispatch(body.op as OperationName, body.input as never);
|
|
48
|
+
return jsonResponse({ result });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// `toString()`, not `.message`: every Lector domain error sets a stable `.name`
|
|
51
|
+
// (StaleExpectedHash, UnknownWorkspace, ...), and Error.prototype.toString()
|
|
52
|
+
// renders it as "<name>: <message>". The RPC client's transport contract only
|
|
53
|
+
// carries a single error string, so this is the seam that lets a caller on the
|
|
54
|
+
// other side of HTTP distinguish error kinds without parsing message prose --
|
|
55
|
+
// check `error.message.startsWith("SomeDomainError: ")`, not full-message matching.
|
|
56
|
+
return errorResponse(error instanceof Error ? error.toString() : String(error), 400);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return errorResponse("not found", 404);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Idle-eviction TTL for warm symbol indexes (LSP subprocesses, mainly): a long-lived,
|
|
66
|
+
* dynamic-workspace daemon accumulates one per project ever queried over its uptime, with
|
|
67
|
+
* no natural point at which a project stops being relevant. 30 minutes matches Oculus's
|
|
68
|
+
* own chosen value for the identical problem (gopls warm-pool TTL eviction) -- a real,
|
|
69
|
+
* previously-shipped resource-growth fix, not an arbitrary number.
|
|
70
|
+
*/
|
|
71
|
+
const DEFAULT_SYMBOL_INDEX_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
72
|
+
const DEFAULT_SYMBOL_INDEX_REAP_INTERVAL_MS = 5 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
export interface LectorDaemonOptions {
|
|
75
|
+
workspaces: ReadonlyMap<WorkspaceId, WorkspacePort>;
|
|
76
|
+
/** Override resolved paths (tests inject an isolated tmp root). Defaults to the real XDG paths. */
|
|
77
|
+
paths?: DaemonPaths;
|
|
78
|
+
logger?: Logger;
|
|
79
|
+
/** Forwarded to createLectorService -- see its own doc comment. Still refuses zero workspaces by default. */
|
|
80
|
+
allowDynamicOnly?: boolean;
|
|
81
|
+
/** Override the idle-eviction TTL for warm symbol indexes. Tests use a short value to observe eviction without waiting. */
|
|
82
|
+
symbolIndexIdleTtlMs?: number;
|
|
83
|
+
/** Override how often the idle-eviction sweep runs. */
|
|
84
|
+
symbolIndexReapIntervalMs?: number;
|
|
85
|
+
/** Override the repo.fetch backend (tests inject a GitRepoFetcher pointed at a local fixture repo, avoiding live network). Defaults to a real GitRepoFetcher under this daemon's own data directory. */
|
|
86
|
+
createRepoFetcher?: () => RepoFetcherPort;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function prepare(options: LectorDaemonOptions): {
|
|
90
|
+
paths: DaemonPaths;
|
|
91
|
+
app: { fetch(request: Request): Promise<Response> };
|
|
92
|
+
onShutdown: () => Promise<void>;
|
|
93
|
+
maintenanceTasks: MaintenanceTask[];
|
|
94
|
+
} {
|
|
95
|
+
const paths = options.paths ?? resolveLectorPaths();
|
|
96
|
+
// One SQLite file per workspace (named by its own deterministic workspaceId) under a
|
|
97
|
+
// sibling directory of the main database -- not the same file, since SqliteSymbolGraph
|
|
98
|
+
// and any other store sharing paths.database would collide on daemon-kit's single
|
|
99
|
+
// PRAGMA user_version migration counter, silently skipping one store's own migrations.
|
|
100
|
+
const symbolGraphDirectory = join(dirname(paths.database), "symbol-graphs");
|
|
101
|
+
// One GitRepoFetcher for the whole daemon (not per-workspace, unlike symbol graphs) -- it
|
|
102
|
+
// manages its own disk-bounded LRU cache of fetched external repos under a sibling
|
|
103
|
+
// directory of the main database, independent of any single registered workspace.
|
|
104
|
+
const reposDirectory = join(dirname(paths.database), "repos");
|
|
105
|
+
// createLectorService throws synchronously on an empty registry (unless allowDynamicOnly is
|
|
106
|
+
// explicitly set), before startDaemon/runDaemonProcess ever binds a listener or writes a
|
|
107
|
+
// handle file -- the daemon fails loudly at construction rather than starting and silently
|
|
108
|
+
// returning empty/error results per call. (Locus LCS-BUG-88 class.)
|
|
109
|
+
const service = createLectorService(options.workspaces, {
|
|
110
|
+
allowDynamicOnly: options.allowDynamicOnly,
|
|
111
|
+
createSymbolGraph: (workspaceId) => new SqliteSymbolGraph(join(symbolGraphDirectory, `${workspaceId}.db`)),
|
|
112
|
+
createRepoFetcher: options.createRepoFetcher ?? (() => new GitRepoFetcher(reposDirectory)),
|
|
113
|
+
// The real production shape the SearchCachePort design was for: an in-memory tier for
|
|
114
|
+
// speed plus a disk-backed tier so repeated searches survive a daemon restart -- a single
|
|
115
|
+
// SearchCachePort adapter can only be one or the other, service.ts's own safe default is
|
|
116
|
+
// in-memory-only.
|
|
117
|
+
createSearchCache: () => new TieredSearchCache(new InMemorySearchCache(), new SqliteSearchCache(join(dirname(paths.database), "search-cache.db"))),
|
|
118
|
+
});
|
|
119
|
+
const token = ensureAuthToken(paths.token, "Lector");
|
|
120
|
+
const idleTtlMs = options.symbolIndexIdleTtlMs ?? DEFAULT_SYMBOL_INDEX_IDLE_TTL_MS;
|
|
121
|
+
// service.close() stops every warm symbol-index (LSP) subprocess the service spawned --
|
|
122
|
+
// without this hook a daemon restart would leak one language server per workspace that
|
|
123
|
+
// had ever run a symbol query. reapIdleSymbolIndexes (below) is the same idea on a timer,
|
|
124
|
+
// for indexes that go idle long before the daemon itself ever restarts.
|
|
125
|
+
return {
|
|
126
|
+
paths,
|
|
127
|
+
app: buildLectorApp(service, token),
|
|
128
|
+
onShutdown: () => service.close(),
|
|
129
|
+
maintenanceTasks: [
|
|
130
|
+
{
|
|
131
|
+
name: "reap-idle-symbol-indexes",
|
|
132
|
+
intervalMs: options.symbolIndexReapIntervalMs ?? DEFAULT_SYMBOL_INDEX_REAP_INTERVAL_MS,
|
|
133
|
+
run: async () => {
|
|
134
|
+
await service.reapIdleSymbolIndexes(idleTtlMs);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** In-process entry point: no signal wiring, returns a stoppable handle. Used by tests and embedders. */
|
|
142
|
+
export function startLectorDaemon(options: LectorDaemonOptions): RunningDaemon {
|
|
143
|
+
const { paths, app, onShutdown, maintenanceTasks } = prepare(options);
|
|
144
|
+
return startDaemon({
|
|
145
|
+
daemonLabel: "Lector",
|
|
146
|
+
handlePath: paths.handle,
|
|
147
|
+
buildApp: () => app,
|
|
148
|
+
logger: options.logger,
|
|
149
|
+
onShutdown,
|
|
150
|
+
maintenanceTasks,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The real binary's entry point: wires SIGINT/SIGTERM and process.exit. */
|
|
155
|
+
export function serveMain(options: LectorDaemonOptions & { onListen?: (info: { host: string; port: number }) => void }): void {
|
|
156
|
+
const { paths, app, onShutdown, maintenanceTasks } = prepare(options);
|
|
157
|
+
runDaemonProcess({
|
|
158
|
+
daemonLabel: "Lector",
|
|
159
|
+
handlePath: paths.handle,
|
|
160
|
+
buildApp: () => app,
|
|
161
|
+
logger: options.logger,
|
|
162
|
+
onShutdown,
|
|
163
|
+
maintenanceTasks,
|
|
164
|
+
onListen: options.onListen,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Raised when a caller-influenced string cannot safely reach git's argv. */
|
|
2
|
+
export class UnsafeGitArgument extends Error {
|
|
3
|
+
constructor(readonly value: string) {
|
|
4
|
+
super(`"${value}" cannot be used as a git argument -- it would be interpreted as a flag, not a literal value`);
|
|
5
|
+
this.name = "UnsafeGitArgument";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rejects any value git's own argv parser could interpret as a flag rather than a literal
|
|
11
|
+
* ref/path -- the root cause of most git CLI injection CVEs (simple-git's own history:
|
|
12
|
+
* `--upload-pack`, `--exec`, `--template`, `-c` config overrides all require the argument
|
|
13
|
+
* to start with `-` to be parsed as a flag at all). Lector's git layer is read-only and never
|
|
14
|
+
* needs to pass a caller-influenced flag, so this is a hard rejection, not a configurable
|
|
15
|
+
* allow-list the way simple-git's own deny-by-category defense is for a general-purpose wrapper.
|
|
16
|
+
*/
|
|
17
|
+
export function assertSafeGitArgument(value: string): void {
|
|
18
|
+
if (value.startsWith("-")) throw new UnsafeGitArgument(value);
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Raised when a caller-influenced value could escape a directory layout built by joining path segments. */
|
|
2
|
+
export class UnsafePathSegment extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
readonly label: string,
|
|
5
|
+
readonly value: string,
|
|
6
|
+
) {
|
|
7
|
+
super(`"${value}" is not a safe ${label} -- must be a single non-empty path segment with no separators or ".." `);
|
|
8
|
+
this.name = "UnsafePathSegment";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Rejects empty, ".", "..", or anything containing a path separator -- the values that let a caller-influenced segment escape a directory it was meant to stay inside. */
|
|
13
|
+
export function assertSafePathSegment(value: string, label: string): void {
|
|
14
|
+
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) {
|
|
15
|
+
throw new UnsafePathSegment(label, value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { assertSafeGitArgument } from "./assert-safe-git-argument.ts";
|
|
2
|
+
import { assertSafePathSegment } from "./assert-safe-path-segment.ts";
|
|
3
|
+
import type { RepoReference } from "./repo-reference.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates every caller-influenced field of a RepoReference before it reaches a git argv or a
|
|
7
|
+
* cache-directory path. host/owner/repo become single directory segments -- no separators, no
|
|
8
|
+
* "..". ref may contain "/" (branch namespacing, e.g. "feature/x") but each of its own segments
|
|
9
|
+
* still can't be "..", and the whole value can't start with "-" (git argv-flag injection).
|
|
10
|
+
*/
|
|
11
|
+
export function assertSafeRepoReference(reference: RepoReference): void {
|
|
12
|
+
assertSafePathSegment(reference.host, "host");
|
|
13
|
+
assertSafePathSegment(reference.owner, "owner");
|
|
14
|
+
assertSafePathSegment(reference.repo, "repo");
|
|
15
|
+
if (reference.ref === null) return;
|
|
16
|
+
assertSafeGitArgument(reference.ref);
|
|
17
|
+
for (const segment of reference.ref.split("/")) {
|
|
18
|
+
assertSafePathSegment(segment, "ref segment");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Raised when a caller-influenced search query could be interpreted as a ripgrep flag rather than a literal pattern. */
|
|
2
|
+
export class UnsafeSearchQuery extends Error {
|
|
3
|
+
constructor(readonly value: string) {
|
|
4
|
+
super(`"${value}" cannot be used as a search query -- it would be interpreted as a flag, not a literal pattern`);
|
|
5
|
+
this.name = "UnsafeSearchQuery";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rejects any value ripgrep's own argv parser could interpret as a flag rather than a literal
|
|
11
|
+
* pattern -- the same root cause as git's own argv-injection class, applied to a different tool.
|
|
12
|
+
* The adapter also passes `--` ahead of the query as defense in depth beyond this check, the
|
|
13
|
+
* same two-layer approach already used for git's diff ref.
|
|
14
|
+
*/
|
|
15
|
+
export function assertSafeSearchQuery(value: string): void {
|
|
16
|
+
if (value.startsWith("-")) throw new UnsafeSearchQuery(value);
|
|
17
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export type JobPriority = "local" | "remote";
|
|
2
|
+
|
|
3
|
+
interface JobIdentity {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly operation: string;
|
|
6
|
+
readonly priority: JobPriority;
|
|
7
|
+
readonly submittedAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type JobSnapshot<Result = unknown> =
|
|
11
|
+
| (JobIdentity & { readonly status: "queued" })
|
|
12
|
+
| (JobIdentity & { readonly status: "running"; readonly startedAt: number })
|
|
13
|
+
| (JobIdentity & { readonly status: "succeeded"; readonly startedAt: number; readonly finishedAt: number; readonly result: Result })
|
|
14
|
+
| (JobIdentity & {
|
|
15
|
+
readonly status: "failed";
|
|
16
|
+
readonly startedAt?: number;
|
|
17
|
+
readonly finishedAt: number;
|
|
18
|
+
readonly error: { readonly code: string; readonly message: string };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export class JobCapacityExceeded extends Error {
|
|
22
|
+
constructor(readonly maxQueued: number) {
|
|
23
|
+
super(`background job queue is full (${maxQueued} queued); wait for a running job to finish before submitting more work`);
|
|
24
|
+
this.name = "JobCapacityExceeded";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class JobNotFound extends Error {
|
|
29
|
+
constructor(readonly jobId: string) {
|
|
30
|
+
super(`background job "${jobId}" is unknown: it expired, was evicted, or belonged to a previous daemon process`);
|
|
31
|
+
this.name = "JobNotFound";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class JobExecutorClosed extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("background job executor is closed");
|
|
38
|
+
this.name = "JobExecutorClosed";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BoundedJobExecutorOptions {
|
|
43
|
+
readonly maxConcurrent: number;
|
|
44
|
+
readonly maxQueued: number;
|
|
45
|
+
readonly maxRetained: number;
|
|
46
|
+
readonly retentionMs: number;
|
|
47
|
+
readonly createId: () => string;
|
|
48
|
+
readonly now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface JobEntry<Result> {
|
|
52
|
+
snapshot: JobSnapshot<Result>;
|
|
53
|
+
readonly run: () => Promise<Result>;
|
|
54
|
+
readonly settled: Set<() => void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MAX_ERROR_MESSAGE_LENGTH = 1_000;
|
|
58
|
+
|
|
59
|
+
/** Runs process-lifetime work with explicit concurrency, queue, retention, and priority bounds. */
|
|
60
|
+
export class BoundedJobExecutor<Result = unknown> {
|
|
61
|
+
readonly #maxConcurrent: number;
|
|
62
|
+
readonly #maxQueued: number;
|
|
63
|
+
readonly #maxRetained: number;
|
|
64
|
+
readonly #retentionMs: number;
|
|
65
|
+
readonly #createId: () => string;
|
|
66
|
+
readonly #now: () => number;
|
|
67
|
+
readonly #jobs = new Map<string, JobEntry<Result>>();
|
|
68
|
+
readonly #queuedIds: string[] = [];
|
|
69
|
+
readonly #terminalIds: string[] = [];
|
|
70
|
+
#running = 0;
|
|
71
|
+
#closed = false;
|
|
72
|
+
|
|
73
|
+
constructor(options: BoundedJobExecutorOptions) {
|
|
74
|
+
for (const [name, value] of [
|
|
75
|
+
["maxConcurrent", options.maxConcurrent],
|
|
76
|
+
["maxQueued", options.maxQueued],
|
|
77
|
+
["maxRetained", options.maxRetained],
|
|
78
|
+
["retentionMs", options.retentionMs],
|
|
79
|
+
] as const) {
|
|
80
|
+
if (!Number.isSafeInteger(value) || value < (name === "maxConcurrent" ? 1 : 0)) {
|
|
81
|
+
throw new RangeError(`${name} must be a ${name === "maxConcurrent" ? "positive" : "non-negative"} safe integer`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.#maxConcurrent = options.maxConcurrent;
|
|
85
|
+
this.#maxQueued = options.maxQueued;
|
|
86
|
+
this.#maxRetained = options.maxRetained;
|
|
87
|
+
this.#retentionMs = options.retentionMs;
|
|
88
|
+
this.#createId = options.createId;
|
|
89
|
+
this.#now = options.now ?? Date.now;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
submit(input: { operation: string; priority: JobPriority; run: () => Promise<Result> }): JobSnapshot<Result> {
|
|
93
|
+
if (this.#closed) throw new JobExecutorClosed();
|
|
94
|
+
this.reap();
|
|
95
|
+
if (this.#running >= this.#maxConcurrent && this.#queuedIds.length >= this.#maxQueued) {
|
|
96
|
+
throw new JobCapacityExceeded(this.#maxQueued);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const id = this.#createId();
|
|
100
|
+
if (this.#jobs.has(id)) throw new Error(`createId returned duplicate background job id "${id}"`);
|
|
101
|
+
const entry: JobEntry<Result> = {
|
|
102
|
+
snapshot: { id, operation: input.operation, priority: input.priority, submittedAt: this.#now(), status: "queued" },
|
|
103
|
+
run: input.run,
|
|
104
|
+
settled: new Set(),
|
|
105
|
+
};
|
|
106
|
+
this.#jobs.set(id, entry);
|
|
107
|
+
if (this.#running < this.#maxConcurrent) this.#start(entry);
|
|
108
|
+
else this.#queuedIds.push(id);
|
|
109
|
+
return entry.snapshot;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
status(jobId: string): JobSnapshot<Result> {
|
|
113
|
+
this.reap();
|
|
114
|
+
const entry = this.#jobs.get(jobId);
|
|
115
|
+
if (!entry) throw new JobNotFound(jobId);
|
|
116
|
+
return entry.snapshot;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async wait(jobId: string, maxWaitMs: number): Promise<JobSnapshot<Result>> {
|
|
120
|
+
if (!Number.isSafeInteger(maxWaitMs) || maxWaitMs < 0) throw new RangeError("maxWaitMs must be a non-negative safe integer");
|
|
121
|
+
const entry = this.#jobs.get(jobId);
|
|
122
|
+
if (!entry) throw new JobNotFound(jobId);
|
|
123
|
+
if (entry.snapshot.status === "succeeded" || entry.snapshot.status === "failed" || maxWaitMs === 0) return entry.snapshot;
|
|
124
|
+
|
|
125
|
+
await new Promise<void>((resolve) => {
|
|
126
|
+
const settled = () => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
entry.settled.delete(settled);
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
entry.settled.add(settled);
|
|
132
|
+
const timer = setTimeout(settled, maxWaitMs);
|
|
133
|
+
});
|
|
134
|
+
return this.status(jobId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
reap(): number {
|
|
138
|
+
const now = this.#now();
|
|
139
|
+
let removed = 0;
|
|
140
|
+
while (this.#terminalIds.length > 0) {
|
|
141
|
+
const id = this.#terminalIds[0];
|
|
142
|
+
if (!id) break;
|
|
143
|
+
const entry = this.#jobs.get(id);
|
|
144
|
+
if (!entry || (entry.snapshot.status !== "succeeded" && entry.snapshot.status !== "failed")) {
|
|
145
|
+
this.#terminalIds.shift();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (now - entry.snapshot.finishedAt <= this.#retentionMs) break;
|
|
149
|
+
this.#terminalIds.shift();
|
|
150
|
+
this.#jobs.delete(id);
|
|
151
|
+
removed++;
|
|
152
|
+
}
|
|
153
|
+
return removed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
close(): void {
|
|
157
|
+
this.#closed = true;
|
|
158
|
+
for (const id of this.#queuedIds.splice(0)) {
|
|
159
|
+
const entry = this.#jobs.get(id);
|
|
160
|
+
if (entry) this.#finishFailure(entry, new JobExecutorClosed());
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#start(entry: JobEntry<Result>): void {
|
|
165
|
+
this.#running++;
|
|
166
|
+
entry.snapshot = { ...entry.snapshot, status: "running", startedAt: this.#now() };
|
|
167
|
+
void Promise.resolve()
|
|
168
|
+
.then(entry.run)
|
|
169
|
+
.then(
|
|
170
|
+
(result) => this.#finishSuccess(entry, result),
|
|
171
|
+
(error: unknown) => this.#finishFailure(entry, error),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#finishSuccess(entry: JobEntry<Result>, result: Result): void {
|
|
176
|
+
if (entry.snapshot.status !== "running") return;
|
|
177
|
+
entry.snapshot = { ...entry.snapshot, status: "succeeded", finishedAt: this.#now(), result };
|
|
178
|
+
this.#finish(entry);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#finishFailure(entry: JobEntry<Result>, error: unknown): void {
|
|
182
|
+
const wasRunning = entry.snapshot.status === "running";
|
|
183
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
184
|
+
entry.snapshot = {
|
|
185
|
+
...entry.snapshot,
|
|
186
|
+
status: "failed",
|
|
187
|
+
finishedAt: this.#now(),
|
|
188
|
+
error: { code: failure.name || "Error", message: failure.message.slice(0, MAX_ERROR_MESSAGE_LENGTH) },
|
|
189
|
+
};
|
|
190
|
+
if (wasRunning) this.#running--;
|
|
191
|
+
this.#recordTerminal(entry);
|
|
192
|
+
if (wasRunning) this.#startNext();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#finish(entry: JobEntry<Result>): void {
|
|
196
|
+
this.#running--;
|
|
197
|
+
this.#recordTerminal(entry);
|
|
198
|
+
this.#startNext();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#recordTerminal(entry: JobEntry<Result>): void {
|
|
202
|
+
this.#terminalIds.push(entry.snapshot.id);
|
|
203
|
+
for (const listener of entry.settled) listener();
|
|
204
|
+
entry.settled.clear();
|
|
205
|
+
while (this.#terminalIds.length > this.#maxRetained) {
|
|
206
|
+
const evictedId = this.#terminalIds.shift();
|
|
207
|
+
if (evictedId) this.#jobs.delete(evictedId);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#startNext(): void {
|
|
212
|
+
if (this.#closed || this.#running >= this.#maxConcurrent || this.#queuedIds.length === 0) return;
|
|
213
|
+
const localIndex = this.#queuedIds.findIndex((id) => this.#jobs.get(id)?.snapshot.priority === "local");
|
|
214
|
+
const index = localIndex >= 0 ? localIndex : 0;
|
|
215
|
+
const [nextId] = this.#queuedIds.splice(index, 1);
|
|
216
|
+
const next = nextId ? this.#jobs.get(nextId) : undefined;
|
|
217
|
+
if (next) this.#start(next);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CodeRange } from "./code-range.ts";
|
|
2
|
+
import type { WorkspaceLocation } from "./workspace-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** One node in a call hierarchy: a function/method the server resolved a position to. */
|
|
5
|
+
export interface CallHierarchyEntry {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly kind: string;
|
|
8
|
+
readonly detail?: string;
|
|
9
|
+
readonly location: WorkspaceLocation;
|
|
10
|
+
readonly range: CodeRange;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A caller of the hierarchy root, and the specific ranges within it that make the call. */
|
|
14
|
+
export interface IncomingCall {
|
|
15
|
+
readonly from: CallHierarchyEntry;
|
|
16
|
+
readonly fromRanges: CodeRange[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A callee of the hierarchy root, and the specific ranges within the root that call it. */
|
|
20
|
+
export interface OutgoingCall {
|
|
21
|
+
readonly to: CallHierarchyEntry;
|
|
22
|
+
readonly fromRanges: CodeRange[];
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A span within one workspace file: 1-indexed line/character, matching
|
|
3
|
+
* WorkspaceLocation's own convention (humans and CLIs present positions
|
|
4
|
+
* 1-indexed; the LSP wire format is 0-indexed -- adapters convert at the
|
|
5
|
+
* boundary, domain code never sees LSP's raw 0-indexed Range).
|
|
6
|
+
*/
|
|
7
|
+
export interface CodeRange {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly start: { readonly line: number; readonly character: number };
|
|
10
|
+
readonly end: { readonly line: number; readonly character: number };
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ContentHash — an opaque fingerprint of a workspace entry's content.
|
|
5
|
+
* The domain does not care which algorithm produces it, only that two
|
|
6
|
+
* reads of unchanged content agree and any content change disagrees.
|
|
7
|
+
* Branded so a raw string can never be passed where a real hash is required.
|
|
8
|
+
*/
|
|
9
|
+
export type ContentHash = string & { readonly __brand: "ContentHash" };
|
|
10
|
+
|
|
11
|
+
/** Compute the ContentHash for a piece of workspace entry content. */
|
|
12
|
+
export function contentHashOf(content: string): ContentHash {
|
|
13
|
+
return createHash("sha256").update(content, "utf-8").digest("hex") as ContentHash;
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CodeRange } from "./code-range.ts";
|
|
2
|
+
|
|
3
|
+
export type DiagnosticSeverity = "error" | "warning" | "information" | "hint";
|
|
4
|
+
|
|
5
|
+
/** One issue a language server reports against a specific range of a file, as of its last analysis. */
|
|
6
|
+
export interface Diagnostic {
|
|
7
|
+
readonly range: CodeRange;
|
|
8
|
+
readonly severity: DiagnosticSeverity;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly source?: string;
|
|
11
|
+
readonly code?: string | number;
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { Diagnostic } from "./diagnostic.ts";
|
|
3
|
+
|
|
4
|
+
/** Every diagnostic currently known for one file (errors, warnings, and below), as of the server's last analysis. */
|
|
5
|
+
export async function diagnostics(index: CodeIntelligencePort, path: string): Promise<Diagnostic[]> {
|
|
6
|
+
return index.diagnostics(path);
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CodeRange } from "./code-range.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A symbol declared in one specific file (LSP textDocument/documentSymbol),
|
|
5
|
+
* hierarchical -- e.g. a class's methods nest under the class -- unlike the
|
|
6
|
+
* flat, workspace-wide WorkspaceSymbol find_symbols already returns. Not
|
|
7
|
+
* every server (or backend) can honestly resolve `children`; when it can't,
|
|
8
|
+
* it is simply absent, never fabricated as an empty array.
|
|
9
|
+
*/
|
|
10
|
+
export interface DocumentSymbolEntry {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
readonly detail?: string;
|
|
14
|
+
/** Encloses the whole declaration, including its body. */
|
|
15
|
+
readonly range: CodeRange;
|
|
16
|
+
/** The narrower span that should be selected/revealed -- typically just the name. */
|
|
17
|
+
readonly selectionRange: CodeRange;
|
|
18
|
+
readonly children?: readonly DocumentSymbolEntry[];
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CodeIntelligencePort } from "../ports/code-intelligence-port.ts";
|
|
2
|
+
import type { DocumentSymbolEntry } from "./document-symbol.ts";
|
|
3
|
+
|
|
4
|
+
/** Every symbol declared in one file, hierarchically. */
|
|
5
|
+
export async function documentSymbols(index: CodeIntelligencePort, path: string): Promise<DocumentSymbolEntry[]> {
|
|
6
|
+
return index.documentSymbols(path);
|
|
7
|
+
}
|