@maxgfr/codeindex 2.17.0 → 2.18.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/README.md +26 -3
- package/docs/MIGRATION.md +107 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +243 -19
- package/scripts/engine.mjs +1768 -723
- package/src/ast/extract.ts +180 -142
- package/src/coupling.ts +0 -0
- package/src/delta.ts +417 -0
- package/src/derived.ts +17 -0
- package/src/engine-cli.ts +201 -26
- package/src/engine.ts +31 -4
- package/src/extract/code.ts +4 -1
- package/src/graph.ts +0 -0
- package/src/mcp.ts +469 -212
- package/src/pool.ts +271 -0
- package/src/preload.ts +159 -0
- package/src/render/scip.ts +80 -20
- package/src/render/symbols-json.ts +3 -2
- package/src/scan.ts +142 -17
- package/src/traverse.ts +164 -0
- package/src/types.ts +1 -1
- package/src/viz.ts +91 -1
package/src/pool.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Parallel extraction across worker_threads.
|
|
2
|
+
//
|
|
3
|
+
// Cold indexing is ~90% per-file extraction, and extractCode is a pure function
|
|
4
|
+
// of (rel, ext, content) — so it parallelises exactly. Measured on
|
|
5
|
+
// facebook/react (4343 JS/TS files, 10-core M5): 4.04s sequential -> 0.94s on
|
|
6
|
+
// eight workers, with peak RSS BELOW the single-threaded full index because
|
|
7
|
+
// each worker's tree-sitter wasm arena is torn down when it exits (nothing
|
|
8
|
+
// inside one process ever returns that memory).
|
|
9
|
+
//
|
|
10
|
+
// Determinism is not weakened. Records come back out of order and are keyed by
|
|
11
|
+
// path, and scanRepo assembles + sorts exactly as it always did; every record is
|
|
12
|
+
// built by the SAME buildCodeRecord the sequential path uses. The one way a
|
|
13
|
+
// worker could produce a different record is by having a different grammar tier
|
|
14
|
+
// than the main thread — so workers report the grammars they actually loaded and
|
|
15
|
+
// any disagreement aborts the whole parallel run back to sequential.
|
|
16
|
+
//
|
|
17
|
+
// Sequential fallback is always available and always correct: any failure to
|
|
18
|
+
// resolve, spawn, or agree returns undefined and the caller scans as before.
|
|
19
|
+
import { existsSync, statSync } from "node:fs";
|
|
20
|
+
import { availableParallelism } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
|
+
import { Worker } from "node:worker_threads";
|
|
24
|
+
import type { FileRecord } from "./types.js";
|
|
25
|
+
import { sha1 } from "./hash.js";
|
|
26
|
+
import { readText, walk } from "./walk.js";
|
|
27
|
+
import { extToLang } from "./lang/registry.js";
|
|
28
|
+
import { ensureGrammars, grammarKeysForExts, grammarReady } from "./ast/loader.js";
|
|
29
|
+
import { buildCodeRecord, keptCodeFiles, scanRepo, type ExtractedRecord, type RepoScan, type ScanOptions } from "./scan.js";
|
|
30
|
+
|
|
31
|
+
// One unit of work: a code file to read, hash and extract.
|
|
32
|
+
interface Job {
|
|
33
|
+
abs: string;
|
|
34
|
+
rel: string;
|
|
35
|
+
ext: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface WorkerInput {
|
|
39
|
+
jobs: Job[];
|
|
40
|
+
grammarKeys: string[];
|
|
41
|
+
maxCallsPerFile?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface WorkerOutput {
|
|
45
|
+
// The grammar keys this worker actually got ready. Compared against the main
|
|
46
|
+
// thread's — a mismatch means this worker would have silently used the regex
|
|
47
|
+
// tier for some language, so the run is discarded.
|
|
48
|
+
ready: string[];
|
|
49
|
+
records: { rel: string; size: number; mtimeMs: number; record: FileRecord }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The URL a worker should import to reach THIS engine.
|
|
53
|
+
//
|
|
54
|
+
// Resolved the way grammars are (src/ast/loader.ts): look next to the running
|
|
55
|
+
// module. When the bundle is the file itself (`scripts/engine.mjs`, whether from
|
|
56
|
+
// npm or vendored) that is the module; when a consumer has re-bundled the engine
|
|
57
|
+
// into their own entry, `engine.mjs` is not adjacent and we return undefined so
|
|
58
|
+
// the caller stays sequential — importing THEIR bundle in a worker would run
|
|
59
|
+
// their top-level code, which is exactly the hazard src/engine.ts:215 warns
|
|
60
|
+
// about.
|
|
61
|
+
function resolveEngineUrl(): string | undefined {
|
|
62
|
+
try {
|
|
63
|
+
const here = fileURLToPath(import.meta.url);
|
|
64
|
+
if (here.endsWith("engine.mjs")) return pathToFileURL(here).href;
|
|
65
|
+
const adjacent = join(dirname(here), "engine.mjs");
|
|
66
|
+
if (existsSync(adjacent)) return pathToFileURL(adjacent).href;
|
|
67
|
+
return undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Deadlock guard for a worker that never answers. Generous on purpose: a shard
|
|
74
|
+
// of a very large repo can legitimately take minutes, and tripping this only
|
|
75
|
+
// costs a fallback to the sequential scan.
|
|
76
|
+
const WORKER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
// How many workers to run. `CODEINDEX_WORKERS` wins when set; 0 or 1 means
|
|
79
|
+
// sequential. Default leaves a core for the main thread and caps at 8 — past
|
|
80
|
+
// that the wasm arena per worker costs more than the wall-clock it saves.
|
|
81
|
+
export function workerCount(requested?: number): number {
|
|
82
|
+
const env = process.env["CODEINDEX_WORKERS"];
|
|
83
|
+
const raw = requested ?? (env !== undefined && env !== "" ? Number(env) : undefined);
|
|
84
|
+
if (raw !== undefined) return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
85
|
+
let cores = 1;
|
|
86
|
+
try {
|
|
87
|
+
cores = availableParallelism();
|
|
88
|
+
} catch {
|
|
89
|
+
cores = 1;
|
|
90
|
+
}
|
|
91
|
+
return Math.max(0, Math.min(cores - 1, 8));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Worker side
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
// Runs inside a worker: load the grammars, then read/hash/extract each job.
|
|
99
|
+
// Exported (and named) so the bootstrap below can find it by name — that lookup
|
|
100
|
+
// failing is itself the signal that we imported something that is not this
|
|
101
|
+
// engine, and the worker exits without touching the caller's data.
|
|
102
|
+
export async function runExtractWorker(input: WorkerInput, post: (out: WorkerOutput) => void): Promise<void> {
|
|
103
|
+
await ensureGrammars(input.grammarKeys);
|
|
104
|
+
const ready = input.grammarKeys.filter((k) => grammarReady(k));
|
|
105
|
+
const records: WorkerOutput["records"] = [];
|
|
106
|
+
for (const job of input.jobs) {
|
|
107
|
+
let size: number;
|
|
108
|
+
let mtimeMs: number;
|
|
109
|
+
try {
|
|
110
|
+
const st = statSync(job.abs);
|
|
111
|
+
size = st.size;
|
|
112
|
+
mtimeMs = st.mtimeMs;
|
|
113
|
+
} catch {
|
|
114
|
+
continue; // vanished mid-run — the main thread re-reads it (and drops it)
|
|
115
|
+
}
|
|
116
|
+
const content = readText(job.abs);
|
|
117
|
+
const record = buildCodeRecord(job.rel, job.ext, size, content, sha1(content), extToLang(job.ext), {
|
|
118
|
+
maxCallsPerFile: input.maxCallsPerFile,
|
|
119
|
+
});
|
|
120
|
+
records.push({ rel: job.rel, size, mtimeMs, record });
|
|
121
|
+
}
|
|
122
|
+
post({ ready, records });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Main side
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
// Extract `jobs` across `count` workers. Returns undefined — meaning "scan
|
|
130
|
+
// sequentially" — when workers cannot be used or cannot be trusted:
|
|
131
|
+
// * the engine URL does not resolve to an on-disk engine.mjs,
|
|
132
|
+
// * a worker fails to spawn, errors, or reports an error payload,
|
|
133
|
+
// * a worker's ready grammar set differs from the main thread's.
|
|
134
|
+
// Never throws.
|
|
135
|
+
export async function extractInParallel(
|
|
136
|
+
jobs: Job[],
|
|
137
|
+
grammarKeys: string[],
|
|
138
|
+
count: number,
|
|
139
|
+
opts: { maxCallsPerFile?: number } = {},
|
|
140
|
+
): Promise<Map<string, ExtractedRecord> | undefined> {
|
|
141
|
+
if (count < 2 || jobs.length === 0) return undefined;
|
|
142
|
+
const engineUrl = resolveEngineUrl();
|
|
143
|
+
if (!engineUrl) return undefined;
|
|
144
|
+
|
|
145
|
+
// Ask workers for exactly the grammars the MAIN THREAD has ready — not for
|
|
146
|
+
// every grammar the repo's extensions imply.
|
|
147
|
+
//
|
|
148
|
+
// This is what makes the tiers agree by construction. Under `--no-ast` (or any
|
|
149
|
+
// caller that never warmed grammars) the main thread would extract with regex,
|
|
150
|
+
// so the workers must too; asking for the walk-derived set instead would have
|
|
151
|
+
// them load wasm, produce AST-tier records, mismatch, and silently drop the
|
|
152
|
+
// whole run back to sequential — losing parallelism on exactly the tier where
|
|
153
|
+
// it is cheapest.
|
|
154
|
+
const wanted = grammarKeys.filter((k) => grammarReady(k)).sort();
|
|
155
|
+
|
|
156
|
+
// Round-robin over the path-sorted job list, so every shard sees the same
|
|
157
|
+
// language mix and no shard loads a grammar the others don't.
|
|
158
|
+
const shards: Job[][] = Array.from({ length: Math.min(count, jobs.length) }, () => []);
|
|
159
|
+
jobs.forEach((j, i) => shards[i % shards.length]!.push(j));
|
|
160
|
+
|
|
161
|
+
const bootstrap =
|
|
162
|
+
`import { runExtractWorker } from ${JSON.stringify(engineUrl)};\n` +
|
|
163
|
+
`import { parentPort, workerData } from "node:worker_threads";\n` +
|
|
164
|
+
`runExtractWorker(workerData.input, (o) => parentPort.postMessage(o))` +
|
|
165
|
+
`.catch((e) => parentPort.postMessage({ error: String(e) }));\n`;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const outputs = await Promise.all(
|
|
169
|
+
shards.map(
|
|
170
|
+
(jobsForShard) =>
|
|
171
|
+
new Promise<WorkerOutput | { error: string }>((resolve, reject) => {
|
|
172
|
+
const w = new Worker(bootstrap, {
|
|
173
|
+
eval: true,
|
|
174
|
+
workerData: { input: { jobs: jobsForShard, grammarKeys: wanted, maxCallsPerFile: opts.maxCallsPerFile } },
|
|
175
|
+
});
|
|
176
|
+
// A worker that neither answers nor errors would hang the whole
|
|
177
|
+
// index, since Promise.all waits forever. The bound is a deadlock
|
|
178
|
+
// guard, not a performance knob — tripping it costs a fallback to
|
|
179
|
+
// the sequential scan, which is always correct.
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
reject(new Error("extraction worker timed out"));
|
|
182
|
+
void w.terminate();
|
|
183
|
+
}, WORKER_TIMEOUT_MS);
|
|
184
|
+
const settle = (fn: () => void): void => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
fn();
|
|
187
|
+
};
|
|
188
|
+
w.once("message", (m: WorkerOutput | { error: string }) => {
|
|
189
|
+
settle(() => resolve(m));
|
|
190
|
+
void w.terminate();
|
|
191
|
+
});
|
|
192
|
+
w.once("error", (e) => settle(() => reject(e)));
|
|
193
|
+
w.once("exit", (code) => {
|
|
194
|
+
// Already-settled rejects are no-ops; terminate() itself exits non-zero.
|
|
195
|
+
if (code !== 0) settle(() => reject(new Error(`extraction worker exited with ${code}`)));
|
|
196
|
+
});
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const out = new Map<string, ExtractedRecord>();
|
|
202
|
+
for (const o of outputs) {
|
|
203
|
+
if ("error" in o) return undefined;
|
|
204
|
+
// A worker that readied a different grammar set would have produced
|
|
205
|
+
// regex-tier records for some language — discard the whole run rather
|
|
206
|
+
// than emit a mix of tiers.
|
|
207
|
+
if (o.ready.slice().sort().join(",") !== wanted.join(",")) return undefined;
|
|
208
|
+
for (const r of o.records) out.set(r.rel, { size: r.size, mtimeMs: r.mtimeMs, record: r.record });
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
} catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// scanRepo, with the code files extracted across workers.
|
|
217
|
+
//
|
|
218
|
+
// The parallel part is strictly a pre-pass: it produces the SAME records
|
|
219
|
+
// scanRepo would have built, hands them over via ScanOptions.extracted, and
|
|
220
|
+
// scanRepo then runs its normal loop — cache fastpaths, docs, ordering and
|
|
221
|
+
// change-tracking all unchanged. Anything that prevents or invalidates the
|
|
222
|
+
// parallel pass simply leaves `extracted` empty, and the result is exactly a
|
|
223
|
+
// sequential scan.
|
|
224
|
+
//
|
|
225
|
+
// `opts.workers` overrides the worker count (0/1 = sequential); absent, see
|
|
226
|
+
// workerCount() and CODEINDEX_WORKERS.
|
|
227
|
+
export async function scanRepoParallel(
|
|
228
|
+
root: string,
|
|
229
|
+
opts: ScanOptions & { workers?: number } = {},
|
|
230
|
+
): Promise<RepoScan> {
|
|
231
|
+
const count = workerCount(opts.workers);
|
|
232
|
+
if (count < 2) return scanRepo(root, opts);
|
|
233
|
+
|
|
234
|
+
// Walk ONCE and reuse it for both the job list and the scan itself.
|
|
235
|
+
const walked =
|
|
236
|
+
opts.precomputedWalk ??
|
|
237
|
+
walk(root, {
|
|
238
|
+
maxFileBytes: opts.maxBytes,
|
|
239
|
+
maxFiles: opts.maxFiles,
|
|
240
|
+
gitignore: opts.gitignore,
|
|
241
|
+
ignoreDirs: opts.ignoreDirs,
|
|
242
|
+
});
|
|
243
|
+
const scanOpts: ScanOptions = { ...opts, precomputedWalk: walked };
|
|
244
|
+
|
|
245
|
+
// Only code files are worth shipping out: docs must be read on the main
|
|
246
|
+
// thread anyway (the graph's mention pass needs their text), and everything
|
|
247
|
+
// else is a read plus a hash with no extraction behind it.
|
|
248
|
+
//
|
|
249
|
+
// Files the cache will serve by its stat fastpath are skipped — extracting
|
|
250
|
+
// them would be work whose result scanRepo discards.
|
|
251
|
+
const jobs: Job[] = [];
|
|
252
|
+
for (const { f } of keptCodeFiles(root, scanOpts)) {
|
|
253
|
+
const cached = opts.cache?.get(f.rel);
|
|
254
|
+
if (
|
|
255
|
+
!opts.fullHash &&
|
|
256
|
+
cached &&
|
|
257
|
+
cached.size !== undefined &&
|
|
258
|
+
cached.mtimeMs !== undefined &&
|
|
259
|
+
cached.size === f.size &&
|
|
260
|
+
cached.mtimeMs === f.mtimeMs
|
|
261
|
+
) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
jobs.push({ abs: f.abs, rel: f.rel, ext: f.ext });
|
|
265
|
+
}
|
|
266
|
+
if (jobs.length === 0) return scanRepo(root, scanOpts);
|
|
267
|
+
|
|
268
|
+
const grammarKeys = grammarKeysForExts(walked.files.map((f) => f.ext));
|
|
269
|
+
const extracted = await extractInParallel(jobs, grammarKeys, count, { maxCallsPerFile: opts.maxCallsPerFile });
|
|
270
|
+
return scanRepo(root, extracted ? { ...scanOpts, extracted } : scanOpts);
|
|
271
|
+
}
|
package/src/preload.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Reusing a persisted `.codeindex/` index instead of rebuilding it.
|
|
2
|
+
//
|
|
3
|
+
// `codeindex index` writes graph.json + symbols.json + cache.json. This module
|
|
4
|
+
// reads them back, and it does so in two independent steps:
|
|
5
|
+
//
|
|
6
|
+
// * cache.json seeds a scan, so every unchanged file takes scan.ts's stat
|
|
7
|
+
// fastpath instead of a read + hash + extraction pass;
|
|
8
|
+
// * when the freshness guard holds, graph.json/symbols.json are deserialized
|
|
9
|
+
// straight in, so the whole downstream pipeline is skipped too.
|
|
10
|
+
//
|
|
11
|
+
// Both are pure optimizations. Seeded records come from scan.ts's own reuse
|
|
12
|
+
// paths, so they are value-identical to a cold scan's, and the guard is the
|
|
13
|
+
// SAME oracle the CLI's index fastpath uses to prove the on-disk artifacts equal
|
|
14
|
+
// a fresh build. Anything absent, stale, corrupt, or mismatched falls back to
|
|
15
|
+
// the cold path exactly — never a throw.
|
|
16
|
+
//
|
|
17
|
+
// This started life inside the MCP server, which was the only caller. The CLI's
|
|
18
|
+
// read commands (search, symbols, callers, graph, repomap, hotspots, deadcode,
|
|
19
|
+
// complexity, mermaid, rules) rebuilt from scratch on every invocation, so
|
|
20
|
+
// `codeindex search` cost a full tree-sitter pass over the repo every time it
|
|
21
|
+
// ran — 6.3s on a 7k-file repo with a fresh index sitting right next to it.
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { ENGINE_VERSION, EXTRACTOR_VERSION, SCHEMA_VERSION } from "./types.js";
|
|
25
|
+
import type { FileRecord, Graph, SymbolIndex } from "./types.js";
|
|
26
|
+
import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
|
|
27
|
+
import type { IndexArtifacts } from "./pipeline.js";
|
|
28
|
+
import { sha1 } from "./hash.js";
|
|
29
|
+
|
|
30
|
+
// The default index location, relative to the repo root.
|
|
31
|
+
export const INDEX_DIR = ".codeindex";
|
|
32
|
+
|
|
33
|
+
export type PersistedCacheEntry = { hash: string; record: FileRecord; size?: number; mtimeMs?: number };
|
|
34
|
+
export type PersistedCacheMap = Map<string, PersistedCacheEntry>;
|
|
35
|
+
|
|
36
|
+
// ADDITIVE cache.json meta describing the artifacts a prior `index` run wrote
|
|
37
|
+
// (see engine-cli.ts's CacheMeta). Old caches lacking these keys simply never
|
|
38
|
+
// pass the guard below — their per-file records are still reused to seed the
|
|
39
|
+
// scan. Only the graph/symbols shas matter here; the embed sidecar has its own
|
|
40
|
+
// memoization path.
|
|
41
|
+
export interface PersistedMeta {
|
|
42
|
+
engineVersion?: string;
|
|
43
|
+
commit?: string;
|
|
44
|
+
graphSha1?: string;
|
|
45
|
+
symbolsSha1?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A scan re-expressed as the `ScanOptions.cache` shape (the exact map the CLI
|
|
49
|
+
// persists as cache.json): rel → (hash, record, size, mtimeMs), so the next
|
|
50
|
+
// scanRepo can take the stat fastpath / hash-match reuse paths against it.
|
|
51
|
+
export function toCacheMap(scan: RepoScan): PersistedCacheMap {
|
|
52
|
+
const m: PersistedCacheMap = new Map();
|
|
53
|
+
for (const f of scan.files) m.set(f.rel, { hash: f.hash, record: f, size: f.size, mtimeMs: scan.mtimes.get(f.rel) });
|
|
54
|
+
return m;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Read <indexDir>/cache.json into the (cacheMap, meta) the preload needs.
|
|
58
|
+
// Per-file records are reusable ONLY when (schemaVersion, extractorVersion)
|
|
59
|
+
// match this engine — the exact gate the CLI applies before trusting a cache —
|
|
60
|
+
// otherwise the whole cache is discarded (cold scan). Any read/parse failure (no
|
|
61
|
+
// index yet, unreadable, malformed) returns undefined: the cold path.
|
|
62
|
+
export function readPersistedIndex(
|
|
63
|
+
repo: string,
|
|
64
|
+
indexDir: string = INDEX_DIR,
|
|
65
|
+
): { cacheMap: PersistedCacheMap; meta: PersistedMeta } | undefined {
|
|
66
|
+
let parsed:
|
|
67
|
+
| ({ schemaVersion?: number; extractorVersion?: number; files?: Record<string, PersistedCacheEntry> } & PersistedMeta)
|
|
68
|
+
| undefined;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(readFileSync(join(repo, indexDir, "cache.json"), "utf8")) as typeof parsed;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION || parsed.extractorVersion !== EXTRACTOR_VERSION || !parsed.files) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
cacheMap: new Map(Object.entries(parsed.files)),
|
|
79
|
+
meta: {
|
|
80
|
+
engineVersion: parsed.engineVersion,
|
|
81
|
+
commit: parsed.commit,
|
|
82
|
+
graphSha1: parsed.graphSha1,
|
|
83
|
+
symbolsSha1: parsed.symbolsSha1,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The freshness guard, applied to a scan seeded from cache.json:
|
|
89
|
+
// contentUnchanged proves this scan's records are the ones that built the
|
|
90
|
+
// on-disk artifacts; engineVersion pins the version stamp graph.json embeds and
|
|
91
|
+
// commit the HEAD it embeds; the sha checks prove the on-disk bytes ARE that
|
|
92
|
+
// build's output. All true ⇒ graph.json/symbols.json are byte-equal to
|
|
93
|
+
// buildArtifactsFromScan(scan) run here, so deserialize them instead of
|
|
94
|
+
// rebuilding. Graph/SymbolIndex are pure JSON POJOs (no Map/Set/typed fields),
|
|
95
|
+
// so JSON.parse is a lossless round-trip — a schemaVersion assert is the only
|
|
96
|
+
// reconstruction needed. ANY failure — a stale scan, a version/commit/sha
|
|
97
|
+
// mismatch, a missing/corrupt/partial artifact, an unexpected schemaVersion —
|
|
98
|
+
// returns undefined so the caller rebuilds. NEVER throws (a corrupt artifact
|
|
99
|
+
// must degrade, not crash the caller).
|
|
100
|
+
export function preloadArtifacts(
|
|
101
|
+
repo: string,
|
|
102
|
+
scan: RepoScan,
|
|
103
|
+
meta: PersistedMeta,
|
|
104
|
+
indexDir: string = INDEX_DIR,
|
|
105
|
+
): IndexArtifacts | undefined {
|
|
106
|
+
if (
|
|
107
|
+
!scan.contentUnchanged ||
|
|
108
|
+
meta.engineVersion !== ENGINE_VERSION ||
|
|
109
|
+
meta.commit !== scan.commit ||
|
|
110
|
+
meta.graphSha1 === undefined ||
|
|
111
|
+
meta.symbolsSha1 === undefined
|
|
112
|
+
) {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const dir = join(repo, indexDir);
|
|
116
|
+
let graphBytes: Buffer;
|
|
117
|
+
let symbolsBytes: Buffer;
|
|
118
|
+
try {
|
|
119
|
+
graphBytes = readFileSync(join(dir, "graph.json"));
|
|
120
|
+
symbolsBytes = readFileSync(join(dir, "symbols.json"));
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined; // a sha'd artifact went missing since cache.json — rebuild
|
|
123
|
+
}
|
|
124
|
+
// sha over the raw bytes; sha1(string) hashes the same UTF-8 bytes writeFileSync
|
|
125
|
+
// put on disk, so this equals the meta sha the CLI computed over the render.
|
|
126
|
+
if (sha1(graphBytes) !== meta.graphSha1 || sha1(symbolsBytes) !== meta.symbolsSha1) {
|
|
127
|
+
return undefined; // tampered / partial / corrupt on-disk bytes — rebuild
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const graph = JSON.parse(graphBytes.toString("utf8")) as Graph;
|
|
131
|
+
const symbols = JSON.parse(symbolsBytes.toString("utf8")) as SymbolIndex;
|
|
132
|
+
if (graph.schemaVersion !== SCHEMA_VERSION || symbols.schemaVersion !== SCHEMA_VERSION) return undefined;
|
|
133
|
+
return { scan, graph, symbols };
|
|
134
|
+
} catch {
|
|
135
|
+
// Unreachable once the shas matched (the bytes are valid JSON this engine
|
|
136
|
+
// wrote), but the contract is "never throw" — degrade to a rebuild.
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Seed a scan from cache.json and, when the guard holds, the artifacts from
|
|
142
|
+
// graph.json/symbols.json. undefined ⇒ no usable persisted index ⇒ the caller
|
|
143
|
+
// takes the cold path unchanged.
|
|
144
|
+
export function preloadSession(
|
|
145
|
+
repo: string,
|
|
146
|
+
opts: Omit<ScanOptions, "cache">,
|
|
147
|
+
indexDir: string = INDEX_DIR,
|
|
148
|
+
): { scan: RepoScan; cacheMap: PersistedCacheMap; arts?: IndexArtifacts } | undefined {
|
|
149
|
+
const persisted = readPersistedIndex(repo, indexDir);
|
|
150
|
+
if (!persisted) return undefined;
|
|
151
|
+
// Seed the scan from the persisted records — scan.ts's stat fastpath + exact
|
|
152
|
+
// content-hash reuse make this value-identical to a cold scan, only cheaper,
|
|
153
|
+
// and it computes the contentUnchanged the artifact guard reads. When the
|
|
154
|
+
// on-disk content drifted from cache.json, changed files are re-read/extracted
|
|
155
|
+
// here exactly as a cold scan would, so the scan stays correct and the guard
|
|
156
|
+
// simply fails (arts undefined → rebuild on demand).
|
|
157
|
+
const scan = scanRepo(repo, { ...opts, cache: persisted.cacheMap });
|
|
158
|
+
return { scan, cacheMap: toCacheMap(scan), arts: preloadArtifacts(repo, scan, persisted.meta, indexDir) };
|
|
159
|
+
}
|
package/src/render/scip.ts
CHANGED
|
@@ -31,12 +31,64 @@ export interface RenderScipOptions {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
|
-
// Protobuf wire-format primitives (proto3). Messages are assembled
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// and length-delimited (2).
|
|
34
|
+
// Protobuf wire-format primitives (proto3). Messages are assembled into byte
|
|
35
|
+
// sinks and embedded into their parent as length-delimited (wire type 2). Only
|
|
36
|
+
// the two wire types we use are implemented: varint (0) and length-delimited (2).
|
|
38
37
|
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
38
|
+
|
|
39
|
+
// A growable byte buffer. This used to be a plain `number[]` — one boxed JS
|
|
40
|
+
// number per OUTPUT BYTE, which on a 7k-file repo cost ~176 MB of RSS to emit
|
|
41
|
+
// an 8 MB index. A doubling Uint8Array holds the same bytes at 1 byte each.
|
|
42
|
+
class Bytes {
|
|
43
|
+
private buf: Uint8Array;
|
|
44
|
+
private len = 0;
|
|
45
|
+
|
|
46
|
+
constructor(capacity = 64) {
|
|
47
|
+
this.buf = new Uint8Array(capacity);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get length(): number {
|
|
51
|
+
return this.len;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private grow(need: number): void {
|
|
55
|
+
if (this.len + need <= this.buf.length) return;
|
|
56
|
+
let cap = this.buf.length * 2 || 64;
|
|
57
|
+
while (cap < this.len + need) cap *= 2;
|
|
58
|
+
const next = new Uint8Array(cap);
|
|
59
|
+
next.set(this.buf.subarray(0, this.len));
|
|
60
|
+
this.buf = next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Rewind without releasing the buffer, so a per-item scratch sink is
|
|
64
|
+
// allocated once per document instead of once per occurrence/symbol.
|
|
65
|
+
reset(): void {
|
|
66
|
+
this.len = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
push(byte: number): void {
|
|
70
|
+
this.grow(1);
|
|
71
|
+
this.buf[this.len++] = byte;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pushAll(src: ArrayLike<number>): void {
|
|
75
|
+
this.grow(src.length);
|
|
76
|
+
if (src instanceof Uint8Array) this.buf.set(src, this.len);
|
|
77
|
+
else for (let i = 0; i < src.length; i++) this.buf[this.len + i] = src[i]!;
|
|
78
|
+
this.len += src.length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A view over exactly the bytes written — no copy. Callers must not retain it
|
|
82
|
+
// across further writes to this sink.
|
|
83
|
+
view(): Uint8Array {
|
|
84
|
+
return this.buf.subarray(0, this.len);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toUint8Array(): Uint8Array {
|
|
88
|
+
return this.buf.slice(0, this.len);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
40
92
|
const utf8 = new TextEncoder();
|
|
41
93
|
|
|
42
94
|
// Unsigned LEB128. Every value we encode (field tags, enum values, ranges,
|
|
@@ -62,7 +114,11 @@ function pushVarintField(out: Bytes, field: number, n: number): void {
|
|
|
62
114
|
function pushLenDelim(out: Bytes, field: number, payload: ArrayLike<number>): void {
|
|
63
115
|
pushTag(out, field, 2);
|
|
64
116
|
pushVarint(out, payload.length);
|
|
65
|
-
|
|
117
|
+
out.pushAll(payload);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pushMessage(out: Bytes, field: number, payload: Bytes): void {
|
|
121
|
+
pushLenDelim(out, field, payload.view());
|
|
66
122
|
}
|
|
67
123
|
|
|
68
124
|
function pushString(out: Bytes, field: number, s: string): void {
|
|
@@ -73,9 +129,9 @@ function pushString(out: Bytes, field: number, s: string): void {
|
|
|
73
129
|
// the concatenated varints of every element (this is the piece the plain
|
|
74
130
|
// per-element encoding would get wrong).
|
|
75
131
|
function pushPackedInt32(out: Bytes, field: number, values: number[]): void {
|
|
76
|
-
const payload
|
|
132
|
+
const payload = new Bytes(values.length * 2);
|
|
77
133
|
for (const v of values) pushVarint(payload, v);
|
|
78
|
-
|
|
134
|
+
pushMessage(out, field, payload);
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
// ---------------------------------------------------------------------------
|
|
@@ -330,25 +386,27 @@ export function renderScip(scan: RepoScan, opts: RenderScipOptions = {}): Uint8A
|
|
|
330
386
|
}))
|
|
331
387
|
.sort((a, b) => byStr(a.symbol, b.symbol));
|
|
332
388
|
|
|
333
|
-
const doc
|
|
389
|
+
const doc = new Bytes(1024);
|
|
334
390
|
pushString(doc, F_DOC_RELPATH, f.rel);
|
|
391
|
+
const ob = new Bytes(64);
|
|
335
392
|
for (const o of occs) {
|
|
336
393
|
const key = `${o.range.join(",")} ${o.roles} ${o.symbol}`;
|
|
337
394
|
if (seenOcc.has(key)) continue;
|
|
338
395
|
seenOcc.add(key);
|
|
339
|
-
|
|
396
|
+
ob.reset();
|
|
340
397
|
pushPackedInt32(ob, F_OCC_RANGE, o.range);
|
|
341
398
|
pushString(ob, F_OCC_SYMBOL, o.symbol);
|
|
342
399
|
if (o.roles !== 0) pushVarintField(ob, F_OCC_ROLES, o.roles);
|
|
343
|
-
|
|
400
|
+
pushMessage(doc, F_DOC_OCCURRENCES, ob);
|
|
344
401
|
}
|
|
402
|
+
const sb = new Bytes(64);
|
|
345
403
|
for (const si of infos) {
|
|
346
|
-
|
|
404
|
+
sb.reset();
|
|
347
405
|
pushString(sb, F_SI_SYMBOL, si.symbol);
|
|
348
406
|
if (si.kind !== undefined) pushVarintField(sb, F_SI_KIND, si.kind);
|
|
349
407
|
pushString(sb, F_SI_DISPLAY_NAME, si.displayName);
|
|
350
408
|
if (si.enclosing) pushString(sb, F_SI_ENCLOSING, si.enclosing);
|
|
351
|
-
|
|
409
|
+
pushMessage(doc, F_DOC_SYMBOLS, sb);
|
|
352
410
|
}
|
|
353
411
|
pushString(doc, F_DOC_LANGUAGE, f.lang);
|
|
354
412
|
pushVarintField(doc, F_DOC_POSITION_ENCODING, POSITION_ENCODING_UTF16);
|
|
@@ -356,19 +414,21 @@ export function renderScip(scan: RepoScan, opts: RenderScipOptions = {}): Uint8A
|
|
|
356
414
|
}
|
|
357
415
|
|
|
358
416
|
// Metadata { tool_info, project_root, text_document_encoding }.
|
|
359
|
-
const toolInfo
|
|
417
|
+
const toolInfo = new Bytes();
|
|
360
418
|
pushString(toolInfo, F_TOOL_NAME, "codeindex");
|
|
361
419
|
pushString(toolInfo, F_TOOL_VERSION, toolVersion);
|
|
362
420
|
|
|
363
|
-
const metadata
|
|
364
|
-
|
|
421
|
+
const metadata = new Bytes();
|
|
422
|
+
pushMessage(metadata, F_META_TOOL_INFO, toolInfo);
|
|
365
423
|
pushString(metadata, F_META_PROJECT_ROOT, projectRoot);
|
|
366
424
|
pushVarintField(metadata, F_META_TEXT_ENCODING, TEXT_ENCODING_UTF8);
|
|
367
425
|
|
|
368
426
|
// Index { metadata, documents }.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
427
|
+
let total = 0;
|
|
428
|
+
for (const d of documents) total += d.length;
|
|
429
|
+
const index = new Bytes(total + metadata.length + 16);
|
|
430
|
+
pushMessage(index, F_INDEX_METADATA, metadata);
|
|
431
|
+
for (const d of documents) pushMessage(index, F_INDEX_DOCUMENTS, d);
|
|
372
432
|
|
|
373
|
-
return
|
|
433
|
+
return index.toUint8Array();
|
|
374
434
|
}
|
|
@@ -2,14 +2,15 @@ import { SCHEMA_VERSION } from "../types.js";
|
|
|
2
2
|
import type { SymbolIndex } from "../types.js";
|
|
3
3
|
import type { RepoScan } from "../scan.js";
|
|
4
4
|
import { byStr } from "../sort.js";
|
|
5
|
-
import {
|
|
5
|
+
import { uniqueDefsFor } from "../derived.js";
|
|
6
6
|
|
|
7
7
|
// Files that REFERENCE each unique exported symbol — code files via their AST
|
|
8
8
|
// identifiers, doc files via naming the symbol. Feeds symbols.json `refs` so
|
|
9
9
|
// `symbols <name>` can answer "where is X used?". Mirrors the graph's use/mention
|
|
10
10
|
// eligibility (unique + distinctive), so refs and edges stay consistent.
|
|
11
11
|
export function computeSymbolRefs(scan: RepoScan): Map<string, Set<string>> {
|
|
12
|
-
|
|
12
|
+
// Memoized: buildGraph built exactly this map moments earlier in the pipeline.
|
|
13
|
+
const unique = uniqueDefsFor(scan);
|
|
13
14
|
const refs = new Map<string, Set<string>>();
|
|
14
15
|
if (!unique.size) return refs;
|
|
15
16
|
const add = (name: string, file: string): void => {
|