@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Popsuevich
|
|
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,57 @@
|
|
|
1
|
+
# @danypops/lector
|
|
2
|
+
|
|
3
|
+
The capability core and daemon half of [Lector](../../README.md) -- domain,
|
|
4
|
+
ports, adapters, and the CLI/systemd service. See the repo root README for
|
|
5
|
+
the overall architecture and how `packages/pi-lector` fits in.
|
|
6
|
+
|
|
7
|
+
## Storage and service
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
$XDG_DATA_HOME/lector/lector.db # content-addressed cache (SQLite, when enabled)
|
|
11
|
+
$XDG_RUNTIME_DIR/lector/{port,token} # private daemon discovery
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
lector service install # write a systemd user unit, enable, and start it
|
|
16
|
+
lector service status
|
|
17
|
+
lector service restart
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The installed service runs `lector serve --dynamic-workspaces`: a long-lived
|
|
21
|
+
background daemon doesn't know upfront which project(s) will use it, so it starts
|
|
22
|
+
with zero pre-registered workspaces and relies entirely on `workspace.registerPath`
|
|
23
|
+
at runtime — the same explicit-registration path a host adapter uses to attach
|
|
24
|
+
whatever directory it's running in.
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
lector workspace register <dir>
|
|
30
|
+
lector workspace read <workspace-id> <path>
|
|
31
|
+
lector workspace edit <workspace-id> <path> --content <text> (--create | --expected-hash <hash>)
|
|
32
|
+
lector workspace symbols <workspace-id> <query>
|
|
33
|
+
lector workspace populate-symbol-graph <workspace-id> --max-files <n> --max-symbols-per-file <n> --background --wait-ms 500
|
|
34
|
+
lector job status <job-id>
|
|
35
|
+
lector workspace cache-status <workspace-id> --max-files <n> --max-symbols-per-file <n>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Background jobs are process-lifetime and bounded. A daemon restart or retention
|
|
39
|
+
expiry makes an old id unavailable; `job status` reports that explicitly. A running
|
|
40
|
+
scan returns its job id and an actionable still-loading state instead of blocking
|
|
41
|
+
the caller.
|
|
42
|
+
|
|
43
|
+
`exactEdit` requires either `--create` (the entry must not already exist) or
|
|
44
|
+
`--expected-hash <hash>` (the entry must currently match that hash) — a write never
|
|
45
|
+
silently overwrites content it didn't know about.
|
|
46
|
+
|
|
47
|
+
## Symbol backends
|
|
48
|
+
|
|
49
|
+
Two independent `SymbolIndexPort` implementations, exercised against a shared
|
|
50
|
+
conformance fixture so they're proven to agree (or documented where they don't),
|
|
51
|
+
not assumed to:
|
|
52
|
+
|
|
53
|
+
- **`typescript-language-server`** (default) — a warm, real `tsserver` process per
|
|
54
|
+
workspace, most accurate when it has a loaded project.
|
|
55
|
+
- **tree-sitter** (via `web-tree-sitter`, WASM — no native toolchain required) — no
|
|
56
|
+
subprocess, always current, results cached per file under a content-addressed key.
|
|
57
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danypops/lector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Platform-neutral filesystem & code-intelligence capability: core, service, and driven adapters",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"lector": "src/cli.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"cli": "bun src/cli.ts",
|
|
16
|
+
"serve": "bun src/cli.ts serve",
|
|
17
|
+
"bench:cold-start": "bun benchmarks/language-server-cold-start.ts"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@danypops/daemon-kit": "^0.3.2",
|
|
21
|
+
"bash-language-server": "^5.6.0",
|
|
22
|
+
"graphology": "^0.26.0",
|
|
23
|
+
"lru-cache": "^11.5.2",
|
|
24
|
+
"pyright": "^1.1.411",
|
|
25
|
+
"simple-git": "^3.36.0",
|
|
26
|
+
"tree-sitter-wasms": "^0.1.13",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"typescript-language-server": "^5.3.0",
|
|
29
|
+
"web-tree-sitter": "0.20.8",
|
|
30
|
+
"yaml-language-server": "^1.24.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"bun-types": "latest"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/DanyPops/lector.git",
|
|
38
|
+
"directory": "packages/lector"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/DanyPops/lector#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/DanyPops/lector/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"files": ["src", "README.md", "LICENSE"]
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Real on-disk size in bytes via `du -sb`, not a hand-rolled recursive stat sum -- `du` already
|
|
8
|
+
* handles hard links and sparse files correctly, a walker built from scratch would not.
|
|
9
|
+
* Linux-only (GNU coreutils `-b`), matching this codebase's existing /proc-based RSS measurement.
|
|
10
|
+
*/
|
|
11
|
+
export async function measureDirectorySizeBytes(path: string): Promise<number> {
|
|
12
|
+
const { stdout } = await execFileAsync("du", ["-sb", path]);
|
|
13
|
+
const [sizeText] = stdout.split("\t");
|
|
14
|
+
const size = Number.parseInt(sizeText ?? "", 10);
|
|
15
|
+
if (!Number.isFinite(size)) {
|
|
16
|
+
throw new Error(`du produced unparseable output for ${path}: ${JSON.stringify(stdout)}`);
|
|
17
|
+
}
|
|
18
|
+
return size;
|
|
19
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Dirent, readdirSync } from "node:fs";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Shared with RipgrepTextSearch -- one skip-list, not two independently-maintained ones. */
|
|
5
|
+
export const SKIP_DIRECTORY_NAMES = new Set(["node_modules", ".git", "dist", "build", "out", "coverage"]);
|
|
6
|
+
|
|
7
|
+
/** Bounded (entry-count-limited, skips node_modules/.git/build output and hidden dirs) recursive source-file scan. */
|
|
8
|
+
export function findSourceFiles(rootPath: string, isSourceExtension: (extension: string) => boolean, maxFiles: number): string[] {
|
|
9
|
+
const files: string[] = [];
|
|
10
|
+
let scanned = 0;
|
|
11
|
+
|
|
12
|
+
const visit = (relativeDir: string): void => {
|
|
13
|
+
if (scanned >= maxFiles) return;
|
|
14
|
+
let entries: Dirent[];
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(join(rootPath, relativeDir), { withFileTypes: true, encoding: "utf-8" });
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (scanned >= maxFiles) return;
|
|
22
|
+
const relativePath = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
if (SKIP_DIRECTORY_NAMES.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
25
|
+
visit(relativePath);
|
|
26
|
+
} else if (entry.isFile() && isSourceExtension(extname(entry.name))) {
|
|
27
|
+
scanned++;
|
|
28
|
+
files.push(relativePath);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
visit("");
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { LRUCache } from "lru-cache";
|
|
5
|
+
import simpleGit from "simple-git";
|
|
6
|
+
import { assertSafeRepoReference } from "../domain/assert-safe-repo-reference.ts";
|
|
7
|
+
import { RepoFetchFailed, type RepoFetchResult } from "../domain/repo-fetch-result.ts";
|
|
8
|
+
import type { RepoReference } from "../domain/repo-reference.ts";
|
|
9
|
+
import type { RepoFetcherPort } from "../ports/repo-fetcher-port.ts";
|
|
10
|
+
import { measureDirectorySizeBytes } from "./directory-size.ts";
|
|
11
|
+
|
|
12
|
+
interface RepoCacheEntry {
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly resolvedRef: string;
|
|
15
|
+
readonly fetchedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INDEX_FILENAME = "index.json";
|
|
19
|
+
const DEFAULT_MAX_CACHE_BYTES = 5 * 1024 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_MAX_ENTRIES = 500;
|
|
21
|
+
|
|
22
|
+
function cacheKey(reference: RepoReference): string {
|
|
23
|
+
return `${reference.host}/${reference.owner}/${reference.repo}/${reference.ref ?? "HEAD"}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GitRepoFetcherOptions {
|
|
27
|
+
/** Disk budget in bytes across every cached clone. Least-recently-fetched clones are deleted once exceeded. */
|
|
28
|
+
readonly maxCacheBytes?: number;
|
|
29
|
+
readonly maxEntries?: number;
|
|
30
|
+
/** Maps a reference to what git should clone from. Defaults to `https://<host>/<owner>/<repo>.git`. Overridable so tests can point at a real local bare-repo fixture instead of the network. */
|
|
31
|
+
readonly resolveCloneUrl?: (reference: RepoReference) => string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultCloneUrl(reference: RepoReference): string {
|
|
35
|
+
return `https://${reference.host}/${reference.owner}/${reference.repo}.git`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* RepoFetcherPort backed by a real `git clone --depth 1`, content-addressed by
|
|
40
|
+
* (host, owner, repo, ref) under `reposDir`. Disk usage is bounded by an LRU cache keyed the same
|
|
41
|
+
* way; evicting an entry deletes its directory, so the cache and the filesystem never disagree
|
|
42
|
+
* about what's actually on disk. Calls are serialized through an in-process queue -- this daemon
|
|
43
|
+
* is the sole writer of reposDir, so a promise chain is sufficient; no cross-process file lock
|
|
44
|
+
* is needed.
|
|
45
|
+
*/
|
|
46
|
+
export class GitRepoFetcher implements RepoFetcherPort {
|
|
47
|
+
private readonly reposDir: string;
|
|
48
|
+
private readonly indexPath: string;
|
|
49
|
+
private readonly resolveCloneUrl: (reference: RepoReference) => string;
|
|
50
|
+
private readonly cache: LRUCache<string, RepoCacheEntry>;
|
|
51
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
52
|
+
|
|
53
|
+
constructor(reposDir: string, options: GitRepoFetcherOptions = {}) {
|
|
54
|
+
this.reposDir = reposDir;
|
|
55
|
+
this.indexPath = join(reposDir, INDEX_FILENAME);
|
|
56
|
+
this.resolveCloneUrl = options.resolveCloneUrl ?? defaultCloneUrl;
|
|
57
|
+
this.cache = new LRUCache<string, RepoCacheEntry>({
|
|
58
|
+
maxSize: options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES,
|
|
59
|
+
// A single clone larger than the whole budget must still be stored (evicting everything
|
|
60
|
+
// else) rather than silently rejected -- lru-cache's own default couples this to maxSize.
|
|
61
|
+
maxEntrySize: Number.MAX_SAFE_INTEGER,
|
|
62
|
+
max: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
|
|
63
|
+
dispose: (entry) => {
|
|
64
|
+
rmSync(entry.path, { recursive: true, force: true });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
this.loadIndex();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async fetch(reference: RepoReference): Promise<RepoFetchResult> {
|
|
71
|
+
assertSafeRepoReference(reference);
|
|
72
|
+
const task = this.queue.then(() => this.fetchLocked(reference));
|
|
73
|
+
this.queue = task.then(
|
|
74
|
+
() => undefined,
|
|
75
|
+
() => undefined,
|
|
76
|
+
);
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async fetchLocked(reference: RepoReference): Promise<RepoFetchResult> {
|
|
81
|
+
const key = cacheKey(reference);
|
|
82
|
+
const cached = this.cache.get(key);
|
|
83
|
+
if (cached) {
|
|
84
|
+
return { path: cached.path, fromCache: true, resolvedRef: cached.resolvedRef, refFallbackOccurred: false };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const targetDir = join(this.reposDir, reference.host, reference.owner, reference.repo, reference.ref ?? "HEAD");
|
|
88
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
89
|
+
const tmpDir = `${targetDir}.tmp-${process.pid}-${Date.now()}`;
|
|
90
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
91
|
+
|
|
92
|
+
const url = this.resolveCloneUrl(reference);
|
|
93
|
+
let resolvedRef = reference.ref ?? "HEAD";
|
|
94
|
+
let refFallbackOccurred = false;
|
|
95
|
+
try {
|
|
96
|
+
await this.clone(url, reference.ref, tmpDir);
|
|
97
|
+
} catch (firstError) {
|
|
98
|
+
if (reference.ref === null) {
|
|
99
|
+
throw new RepoFetchFailed(reference.host, reference.owner, reference.repo, reference.ref, firstError);
|
|
100
|
+
}
|
|
101
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
102
|
+
try {
|
|
103
|
+
await this.clone(url, null, tmpDir);
|
|
104
|
+
refFallbackOccurred = true;
|
|
105
|
+
resolvedRef = "HEAD";
|
|
106
|
+
} catch (secondError) {
|
|
107
|
+
throw new RepoFetchFailed(reference.host, reference.owner, reference.repo, reference.ref, secondError);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await rm(join(tmpDir, ".git"), { recursive: true, force: true });
|
|
112
|
+
await mkdir(dirname(targetDir), { recursive: true });
|
|
113
|
+
await rename(tmpDir, targetDir);
|
|
114
|
+
|
|
115
|
+
const sizeBytes = await measureDirectorySizeBytes(targetDir);
|
|
116
|
+
const entry: RepoCacheEntry = { path: targetDir, resolvedRef, fetchedAt: Date.now() };
|
|
117
|
+
this.cache.set(key, entry, { size: sizeBytes });
|
|
118
|
+
this.persistIndex();
|
|
119
|
+
|
|
120
|
+
return { path: targetDir, fromCache: false, resolvedRef, refFallbackOccurred };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async clone(url: string, ref: string | null, targetDir: string): Promise<void> {
|
|
124
|
+
const options = ref ? ["--depth", "1", "--branch", ref] : ["--depth", "1"];
|
|
125
|
+
await simpleGit().clone(url, targetDir, options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private loadIndex(): void {
|
|
129
|
+
if (!existsSync(this.indexPath)) return;
|
|
130
|
+
try {
|
|
131
|
+
const dumped = JSON.parse(readFileSync(this.indexPath, "utf8")) as Parameters<typeof this.cache.load>[0];
|
|
132
|
+
this.cache.load(dumped);
|
|
133
|
+
} catch {
|
|
134
|
+
// Corrupt or unreadable index -- start fresh, do not throw. Directories left over from a
|
|
135
|
+
// prior run are cleaned up lazily the next time their key is fetched again (fetchLocked
|
|
136
|
+
// always rm's targetDir before cloning into it).
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private persistIndex(): void {
|
|
141
|
+
mkdirSync(this.reposDir, { recursive: true });
|
|
142
|
+
const temp = `${this.indexPath}.${process.pid}.tmp`;
|
|
143
|
+
writeFileSync(temp, JSON.stringify(this.cache.dump()), "utf8");
|
|
144
|
+
renameSync(temp, this.indexPath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ContentHash } from "../domain/content-hash.ts";
|
|
2
|
+
import type { ContentCacheEntry, ContentCachePort, ContentSymbol } from "../ports/content-cache-port.ts";
|
|
3
|
+
|
|
4
|
+
/** In-process ContentCachePort -- no persistence, cleared on process exit. The default when no durable cache is configured. */
|
|
5
|
+
export class InMemoryContentCache implements ContentCachePort {
|
|
6
|
+
private readonly entries = new Map<ContentHash, ContentCacheEntry>();
|
|
7
|
+
|
|
8
|
+
async get(hash: ContentHash): Promise<ContentCacheEntry | undefined> {
|
|
9
|
+
return this.entries.get(hash);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async putRawContent(hash: ContentHash, content: string): Promise<void> {
|
|
13
|
+
this.entries.set(hash, { ...this.entries.get(hash), rawContent: content });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async putSymbols(hash: ContentHash, symbols: readonly ContentSymbol[]): Promise<void> {
|
|
17
|
+
this.entries.set(hash, { ...this.entries.get(hash), symbols });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { LRUCache } from "lru-cache";
|
|
2
|
+
import { deriveSearchCacheKey, type SearchCacheKey } from "../domain/search-cache-key.ts";
|
|
3
|
+
import type { TextSearchResult } from "../domain/text-search-result.ts";
|
|
4
|
+
import type { SearchCachePort } from "../ports/search-cache-port.ts";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_ENTRIES = 200;
|
|
7
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
export interface InMemorySearchCacheOptions {
|
|
10
|
+
readonly maxEntries?: number;
|
|
11
|
+
readonly ttlMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** SearchCachePort backed by lru-cache -- count- and TTL-bounded, not a hand-rolled Map+timestamp cache. */
|
|
15
|
+
export class InMemorySearchCache implements SearchCachePort {
|
|
16
|
+
private readonly cache: LRUCache<string, TextSearchResult>;
|
|
17
|
+
|
|
18
|
+
constructor(options: InMemorySearchCacheOptions = {}) {
|
|
19
|
+
this.cache = new LRUCache<string, TextSearchResult>({
|
|
20
|
+
max: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
|
|
21
|
+
ttl: options.ttlMs ?? DEFAULT_TTL_MS,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get(key: SearchCacheKey): Promise<TextSearchResult | undefined> {
|
|
26
|
+
return this.cache.get(deriveSearchCacheKey(key));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async set(key: SearchCacheKey, result: TextSearchResult): Promise<void> {
|
|
30
|
+
this.cache.set(deriveSearchCacheKey(key), result);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Graph from "graphology";
|
|
2
|
+
import type { SymbolGraphGeneration } from "../domain/symbol-graph-generation.ts";
|
|
3
|
+
import type { SymbolNodeId } from "../domain/symbol-node-id.ts";
|
|
4
|
+
import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "../ports/symbol-graph-port.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory SymbolGraphPort for tests and small/ephemeral workspaces,
|
|
8
|
+
* backed by graphology for node/edge storage (multi-edge and self-loop
|
|
9
|
+
* support). reachableFrom is a small explicit bounded-depth BFS on top of
|
|
10
|
+
* it, since graphology's own traversal helpers don't take a hop limit.
|
|
11
|
+
*/
|
|
12
|
+
export class InMemorySymbolGraph implements SymbolGraphPort {
|
|
13
|
+
private readonly graph = new Graph({ type: "directed", multi: true, allowSelfLoops: true });
|
|
14
|
+
private readonly nodes = new Map<SymbolNodeId, SymbolNode>();
|
|
15
|
+
private generation: SymbolGraphGeneration | undefined;
|
|
16
|
+
|
|
17
|
+
async addNode(node: SymbolNode): Promise<void> {
|
|
18
|
+
this.nodes.set(node.id, node);
|
|
19
|
+
if (!this.graph.hasNode(node.id)) this.graph.addNode(node.id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async getNode(id: SymbolNodeId): Promise<SymbolNode | undefined> {
|
|
23
|
+
return this.nodes.get(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async addEdge(from: SymbolNodeId, to: SymbolNodeId, kind: SymbolEdgeKind): Promise<void> {
|
|
27
|
+
if (!this.graph.hasNode(from)) this.graph.addNode(from);
|
|
28
|
+
if (!this.graph.hasNode(to)) this.graph.addNode(to);
|
|
29
|
+
const edgeKey = `${from}->${to}:${kind}`;
|
|
30
|
+
if (!this.graph.hasEdge(edgeKey)) this.graph.addEdgeWithKey(edgeKey, from, to, { kind });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async edgesFrom(id: SymbolNodeId, kind?: SymbolEdgeKind): Promise<readonly SymbolNodeId[]> {
|
|
34
|
+
if (!this.graph.hasNode(id)) return [];
|
|
35
|
+
return this.graph
|
|
36
|
+
.outEdges(id)
|
|
37
|
+
.filter((edgeKey) => !kind || this.graph.getEdgeAttribute(edgeKey, "kind") === kind)
|
|
38
|
+
.map((edgeKey) => this.graph.target(edgeKey));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async edgesTo(id: SymbolNodeId, kind?: SymbolEdgeKind): Promise<readonly SymbolNodeId[]> {
|
|
42
|
+
if (!this.graph.hasNode(id)) return [];
|
|
43
|
+
return this.graph
|
|
44
|
+
.inEdges(id)
|
|
45
|
+
.filter((edgeKey) => !kind || this.graph.getEdgeAttribute(edgeKey, "kind") === kind)
|
|
46
|
+
.map((edgeKey) => this.graph.source(edgeKey));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async reachableFrom(id: SymbolNodeId, options: { maxDepth: number; kind?: SymbolEdgeKind }): Promise<readonly SymbolNodeId[]> {
|
|
50
|
+
if (options.maxDepth < 1 || !this.graph.hasNode(id)) return [];
|
|
51
|
+
const reached = new Set<SymbolNodeId>();
|
|
52
|
+
let frontier = new Set<SymbolNodeId>([id]);
|
|
53
|
+
for (let depth = 0; depth < options.maxDepth && frontier.size > 0; depth++) {
|
|
54
|
+
const next = new Set<SymbolNodeId>();
|
|
55
|
+
for (const current of frontier) {
|
|
56
|
+
for (const neighborId of await this.edgesFrom(current, options.kind)) {
|
|
57
|
+
if (neighborId === id || reached.has(neighborId)) continue;
|
|
58
|
+
reached.add(neighborId);
|
|
59
|
+
next.add(neighborId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
frontier = next;
|
|
63
|
+
}
|
|
64
|
+
return Array.from(reached);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getGeneration(): Promise<SymbolGraphGeneration | undefined> {
|
|
68
|
+
return this.generation;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async setGeneration(generation: SymbolGraphGeneration): Promise<void> {
|
|
72
|
+
this.generation = generation;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async close(): Promise<void> {
|
|
76
|
+
this.graph.clear();
|
|
77
|
+
this.nodes.clear();
|
|
78
|
+
this.generation = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ContentHash, contentHashOf } from "../domain/content-hash.ts";
|
|
2
|
+
import { StaleExpectedHash } from "../domain/exact-edit.ts";
|
|
3
|
+
import type { WorkspaceEntry, WorkspacePort } from "../ports/workspace-port.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* InMemoryWorkspace — a WorkspacePort backed by a plain Map, for the walking
|
|
7
|
+
* skeleton and for shared conformance fixtures. Holds no file descriptors,
|
|
8
|
+
* watches nothing, and is discarded with the process; a local-filesystem
|
|
9
|
+
* adapter implements the same port for real workspaces.
|
|
10
|
+
*/
|
|
11
|
+
export class InMemoryWorkspace implements WorkspacePort {
|
|
12
|
+
private readonly entries = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
async readEntry(path: string): Promise<WorkspaceEntry> {
|
|
15
|
+
const content = this.entries.get(path);
|
|
16
|
+
return content === undefined ? { exists: false } : { exists: true, content };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async writeEntry(path: string, expectedHash: ContentHash | null, content: string): Promise<{ previousHash: ContentHash | null; newHash: ContentHash }> {
|
|
20
|
+
const existing = this.entries.get(path);
|
|
21
|
+
const previousHash = existing === undefined ? null : contentHashOf(existing);
|
|
22
|
+
if (previousHash !== expectedHash) {
|
|
23
|
+
throw new StaleExpectedHash(path, expectedHash, previousHash);
|
|
24
|
+
}
|
|
25
|
+
this.entries.set(path, content);
|
|
26
|
+
return { previousHash, newHash: contentHashOf(content) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { type ContentHash, contentHashOf } from "../domain/content-hash.ts";
|
|
5
|
+
import { StaleExpectedHash } from "../domain/exact-edit.ts";
|
|
6
|
+
import type { WorkspaceEntry, WorkspacePort } from "../ports/workspace-port.ts";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_NEW_FILE_MODE = 0o644;
|
|
9
|
+
const PERMISSION_BITS_MASK = 0o777;
|
|
10
|
+
|
|
11
|
+
/** Raised when a path would resolve outside the workspace's own root directory. */
|
|
12
|
+
export class PathEscapesWorkspaceRoot extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
readonly path: string,
|
|
15
|
+
readonly root: string,
|
|
16
|
+
) {
|
|
17
|
+
super(`path "${path}" escapes workspace root "${root}"`);
|
|
18
|
+
this.name = "PathEscapesWorkspaceRoot";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEnoent(error: unknown): boolean {
|
|
23
|
+
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* LocalFilesystemWorkspace -- a WorkspacePort backed by real files under one
|
|
28
|
+
* root directory, with expected-hash-guarded atomic writes.
|
|
29
|
+
*
|
|
30
|
+
* Writes go through a same-directory temp file, chmod'd to the target's
|
|
31
|
+
* existing mode before the rename (temp files default to a more
|
|
32
|
+
* restrictive mode, which a naive rename would otherwise leave in place).
|
|
33
|
+
*/
|
|
34
|
+
export class LocalFilesystemWorkspace implements WorkspacePort {
|
|
35
|
+
private readonly root: string;
|
|
36
|
+
|
|
37
|
+
constructor(root: string) {
|
|
38
|
+
this.root = resolve(root);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private resolvePath(path: string): string {
|
|
42
|
+
const absolute = resolve(this.root, path);
|
|
43
|
+
// node:path's relative(), not string-prefix concatenation: `this.root + sep` breaks for
|
|
44
|
+
// the filesystem root itself ("/" + "/" = "//", which no real absolute path starts
|
|
45
|
+
// with -- root="/" is a real, legitimate case: a path with no enclosing project root
|
|
46
|
+
// falls back to it, per pi-lector's workspaceForPath, and every such read was rejected
|
|
47
|
+
// as "escaping" a root it did not actually escape at all).
|
|
48
|
+
const relativeToRoot = relative(this.root, absolute);
|
|
49
|
+
if (relativeToRoot === ".." || relativeToRoot.startsWith(".." + sep) || isAbsolute(relativeToRoot)) {
|
|
50
|
+
throw new PathEscapesWorkspaceRoot(path, this.root);
|
|
51
|
+
}
|
|
52
|
+
return absolute;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async readEntry(path: string): Promise<WorkspaceEntry> {
|
|
56
|
+
const absolute = this.resolvePath(path);
|
|
57
|
+
try {
|
|
58
|
+
const content = await readFile(absolute, "utf-8");
|
|
59
|
+
return { exists: true, content };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (isEnoent(error)) return { exists: false };
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async writeEntry(path: string, expectedHash: ContentHash | null, content: string): Promise<{ previousHash: ContentHash | null; newHash: ContentHash }> {
|
|
67
|
+
const absolute = this.resolvePath(path);
|
|
68
|
+
|
|
69
|
+
let previousHash: ContentHash | null = null;
|
|
70
|
+
let previousMode: number | undefined;
|
|
71
|
+
try {
|
|
72
|
+
const [existingContent, stats] = await Promise.all([readFile(absolute, "utf-8"), stat(absolute)]);
|
|
73
|
+
previousHash = contentHashOf(existingContent);
|
|
74
|
+
previousMode = stats.mode & PERMISSION_BITS_MASK;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isEnoent(error)) throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (previousHash !== expectedHash) {
|
|
80
|
+
throw new StaleExpectedHash(path, expectedHash, previousHash);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
84
|
+
const tempPath = join(dirname(absolute), `.lector-${randomBytes(8).toString("hex")}.tmp`);
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(tempPath, content, "utf-8");
|
|
87
|
+
await chmod(tempPath, previousMode ?? DEFAULT_NEW_FILE_MODE);
|
|
88
|
+
await rename(tempPath, absolute);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await rm(tempPath, { force: true });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { previousHash, newHash: contentHashOf(content) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import simpleGit from "simple-git";
|
|
2
|
+
import { assertSafeGitArgument } from "../domain/assert-safe-git-argument.ts";
|
|
3
|
+
import type { GitDiffResult } from "../domain/git-diff-result.ts";
|
|
4
|
+
import type { GitLogEntry } from "../domain/git-log-entry.ts";
|
|
5
|
+
import type { GitStatusSummary } from "../domain/git-status.ts";
|
|
6
|
+
import type { GitPort } from "../ports/git-port.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GitPort backed by simple-git rather than a hand-rolled execFile wrapper --
|
|
10
|
+
* its status/log parsers handle real cases (staged-vs-unstaged distinction,
|
|
11
|
+
* branch ahead/behind) a first-pass implementation missed, and its
|
|
12
|
+
* `blockUnsafeOperationsPlugin` (deny-by-category: `-c` config overrides,
|
|
13
|
+
* `--upload-pack`, `--exec`, etc.) is registered unconditionally by
|
|
14
|
+
* `simpleGit()` itself -- on by default, not something this adapter has to
|
|
15
|
+
* opt into. assertSafeGitArgument on `ref` is deliberate defense in depth
|
|
16
|
+
* on top of that, not a replacement for it.
|
|
17
|
+
*/
|
|
18
|
+
export class LocalGit implements GitPort {
|
|
19
|
+
private readonly git: ReturnType<typeof simpleGit>;
|
|
20
|
+
|
|
21
|
+
constructor(cwd: string) {
|
|
22
|
+
this.git = simpleGit(cwd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async isGitRepository(): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
return await this.git.checkIsRepo();
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async status(): Promise<GitStatusSummary> {
|
|
34
|
+
const result = await this.git.status();
|
|
35
|
+
return {
|
|
36
|
+
files: result.files.map((file) =>
|
|
37
|
+
file.from
|
|
38
|
+
? { path: file.path, renamedFrom: file.from, indexStatus: file.index, workingDirStatus: file.working_dir }
|
|
39
|
+
: { path: file.path, indexStatus: file.index, workingDirStatus: file.working_dir },
|
|
40
|
+
),
|
|
41
|
+
ahead: result.ahead,
|
|
42
|
+
behind: result.behind,
|
|
43
|
+
current: result.current,
|
|
44
|
+
tracking: result.tracking,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async log(maxCount: number): Promise<readonly GitLogEntry[]> {
|
|
49
|
+
const result = await this.git.log({ maxCount });
|
|
50
|
+
return result.all.map((entry) => ({
|
|
51
|
+
sha: entry.hash,
|
|
52
|
+
authorName: entry.author_name,
|
|
53
|
+
authorEmail: entry.author_email,
|
|
54
|
+
authoredAt: entry.date,
|
|
55
|
+
message: entry.message,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async diff(ref: string | undefined, maxBytes: number): Promise<GitDiffResult> {
|
|
60
|
+
const args: string[] = [];
|
|
61
|
+
if (ref !== undefined) {
|
|
62
|
+
assertSafeGitArgument(ref);
|
|
63
|
+
args.push(ref);
|
|
64
|
+
}
|
|
65
|
+
// Marks the end of options: even a validated ref can never be reinterpreted as a flag by
|
|
66
|
+
// git itself past this point -- defense in depth beyond assertSafeGitArgument and simple-git's
|
|
67
|
+
// own blockUnsafeOperationsPlugin.
|
|
68
|
+
args.push("--");
|
|
69
|
+
const raw = await this.git.diff(args);
|
|
70
|
+
const truncated = raw.length > maxBytes;
|
|
71
|
+
return { diff: truncated ? raw.slice(0, maxBytes) : raw, truncated };
|
|
72
|
+
}
|
|
73
|
+
}
|