@maxgfr/codeindex 2.17.1 → 2.19.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/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
+ }
@@ -31,12 +31,64 @@ export interface RenderScipOptions {
31
31
  }
32
32
 
33
33
  // ---------------------------------------------------------------------------
34
- // Protobuf wire-format primitives (proto3). Messages are assembled as plain
35
- // number[] byte buffers and embedded into their parent as length-delimited
36
- // (wire type 2). Only the two wire types we use are implemented: varint (0)
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
- type Bytes = number[];
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
- for (let i = 0; i < payload.length; i++) out.push(payload[i]!);
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: Bytes = [];
132
+ const payload = new Bytes(values.length * 2);
77
133
  for (const v of values) pushVarint(payload, v);
78
- pushLenDelim(out, field, payload);
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: Bytes = [];
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
- const ob: Bytes = [];
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
- pushLenDelim(doc, F_DOC_OCCURRENCES, ob);
400
+ pushMessage(doc, F_DOC_OCCURRENCES, ob);
344
401
  }
402
+ const sb = new Bytes(64);
345
403
  for (const si of infos) {
346
- const sb: Bytes = [];
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
- pushLenDelim(doc, F_DOC_SYMBOLS, sb);
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: Bytes = [];
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: Bytes = [];
364
- pushLenDelim(metadata, F_META_TOOL_INFO, toolInfo);
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
- const index: Bytes = [];
370
- pushLenDelim(index, F_INDEX_METADATA, metadata);
371
- for (const d of documents) pushLenDelim(index, F_INDEX_DOCUMENTS, d);
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 Uint8Array.from(index);
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 { uniqueSymbolDefs } from "../graph.js";
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
- const unique = uniqueSymbolDefs(scan);
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 => {