@maxgfr/codeindex 2.18.0 → 2.19.1

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/delta.ts CHANGED
@@ -9,6 +9,7 @@ import type { Graph, ModuleNode, SymbolIndex } from "./types.js";
9
9
  import type { DiffFile, DiffSpec, Hunk } from "./git.js";
10
10
  import { isGitWorktree, resolveBaseRef, diffFiles, diffHunks, untrackedFiles } from "./git.js";
11
11
  import { byStr } from "./sort.js";
12
+ import { IGNORE_DIRS } from "./walk.js";
12
13
  import { have, sh } from "./util.js";
13
14
  import { impactOf } from "./traverse.js";
14
15
 
@@ -208,6 +209,24 @@ export function computeDelta(
208
209
  .map((e) => ({ from: e.from, spec: e.to, reason: e.reason ?? "unknown" }))
209
210
  .sort((a, b) => byStr(a.from, b.from) || byStr(a.spec, b.spec));
210
211
 
212
+ // A relative import whose target lands in a directory the walker ignores
213
+ // (vendor/, dist/, build/ …) is reported as dangling because the resolver only
214
+ // looks inside the scan — but the file is there and the import works. It is a
215
+ // blind spot in the graph, not a broken reference, so it must NOT carry the
216
+ // dangling RISK weight: a repo that vendors a dependency would otherwise take
217
+ // a permanent penalty on every change to the file that imports it. Still
218
+ // listed above, so the blind spot stays visible.
219
+ const intoIgnoredTree = (from: string, spec: string): boolean => {
220
+ if (!spec.startsWith(".")) return false;
221
+ const segs = from.split("/").slice(0, -1);
222
+ for (const part of spec.split("/")) {
223
+ if (part === "." || part === "") continue;
224
+ if (part === "..") segs.pop();
225
+ else segs.push(part);
226
+ }
227
+ return segs.some((seg) => IGNORE_DIRS.has(seg));
228
+ };
229
+
211
230
  // Group by module and score.
212
231
  const byModule = new Map<string, DeltaChange[]>();
213
232
  for (const c of changes) {
@@ -296,7 +315,9 @@ export function computeDelta(
296
315
  }
297
316
 
298
317
  // 6. Dangling imports from this module's changed files.
299
- const moduleDangling = dangling.filter((d) => moduleChanges.some((c) => c.path === d.from));
318
+ const moduleDangling = dangling.filter(
319
+ (d) => moduleChanges.some((c) => c.path === d.from) && !intoIgnoredTree(d.from, d.spec),
320
+ );
300
321
  if (moduleDangling.length) {
301
322
  score += RISK_WEIGHTS.dangling;
302
323
  const first = moduleDangling[0]!;
@@ -0,0 +1,176 @@
1
+ // MCP wire concerns: protocol-version negotiation, argument validation, and the
2
+ // response-size guard. Everything here is a pure function of its inputs — no
3
+ // scan, no filesystem beyond checking whether a persisted artifact exists — so
4
+ // it is unit-testable without standing up a server.
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { INDEX_DIR } from "../preload.js";
9
+
10
+ // --- protocol versions -------------------------------------------------------
11
+ // The server announced "2024-11-05" hard-coded and never even read the version
12
+ // the client asked for. Three revisions have shipped since.
13
+ //
14
+ // Negotiation is what makes moving forward non-breaking: a client that asks for
15
+ // an old revision gets that revision, and every field introduced later is
16
+ // withheld — so its responses are exactly the bytes it received before. Newer
17
+ // clients opt themselves in simply by asking.
18
+ //
19
+ // Dates sort lexicographically, so `>=` on the strings is a version comparison.
20
+ export const PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const;
21
+ const LATEST_PROTOCOL = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1]!;
22
+
23
+ // Feature floors, by the revision that introduced them.
24
+ export const ANNOTATIONS_SINCE = "2025-03-26"; // tool behaviour hints
25
+ export const RICH_TOOLS_SINCE = "2025-06-18"; // Tool.title, resource_link content
26
+
27
+ // Validate `arguments` against the tool's declared inputSchema.
28
+ //
29
+ // There was no validation at all beyond presence checks, and the readers failed
30
+ // silently in both directions: str() returns undefined for a non-string, so a
31
+ // number where a path belongs became "missing", and every boolean was `=== true`,
32
+ // so `"false"` and `1` alike read as false. The caller saw its option ignored
33
+ // with no way to tell why.
34
+ //
35
+ // Only the shapes these schemas actually use are checked (string / number /
36
+ // boolean / array-of-string) — this is a guard against silent misreads, not a
37
+ // JSON Schema implementation. The spec (2025-11-25) is explicit that input
38
+ // validation failures belong in a Tool Execution Error, not a protocol error,
39
+ // precisely so the model can read the message and retry.
40
+ // Required-ness stays with callTool, which raises tool-specific messages
41
+ // ("`rules` (or `configPath`) is required"); duplicating it here would only let
42
+ // the two drift.
43
+ export function validateArgs(
44
+ schema: { properties?: Record<string, unknown> },
45
+ args: Record<string, unknown>,
46
+ ): string | undefined {
47
+ const props = (schema.properties ?? {}) as Record<string, { type?: string; items?: { type?: string } }>;
48
+ for (const [key, value] of Object.entries(args)) {
49
+ if (value === undefined || value === null) continue;
50
+ const spec = props[key];
51
+ if (!spec?.type) continue; // undeclared extras stay tolerated
52
+ const actual = Array.isArray(value) ? "array" : typeof value;
53
+ if (spec.type === "number") {
54
+ // A numeric string is accepted (num() coerces it); anything else is not.
55
+ if (actual === "number") continue;
56
+ if (actual === "string" && Number.isFinite(Number(value as string)) && (value as string).trim() !== "") continue;
57
+ return `\`${key}\` must be a number, got ${actual === "string" ? JSON.stringify(value) : actual}`;
58
+ }
59
+ if (spec.type === "array") {
60
+ if (actual !== "array") return `\`${key}\` must be an array of strings, got ${actual}`;
61
+ if (spec.items?.type === "string" && !(value as unknown[]).every((x) => typeof x === "string")) {
62
+ return `\`${key}\` must be an array of strings`;
63
+ }
64
+ continue;
65
+ }
66
+ if (actual !== spec.type) return `\`${key}\` must be a ${spec.type}, got ${actual}`;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ // The structuredContent for a tool response, or undefined when there must not
72
+ // be one.
73
+ //
74
+ // Emitted only when ALL of these hold, because the spec requires a declared
75
+ // outputSchema to be honoured by every structured result:
76
+ // * the tool declares an outputSchema (see OUTPUT_SCHEMAS),
77
+ // * the response was NOT replaced by the size guard — the truncation notice
78
+ // is a different shape and would not conform,
79
+ // * the text parses to a JSON object (never an array: structuredContent is
80
+ // specified as an object).
81
+ // The text block is left exactly as it was, so this is purely additive and
82
+ // content stays the serialization of structuredContent, as the spec asks.
83
+ export function structuredContentFor(text: string, capped: boolean, hasSchema: boolean): Record<string, unknown> | undefined {
84
+ if (capped || !hasSchema) return undefined;
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(text);
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
92
+ return parsed as Record<string, unknown>;
93
+ }
94
+
95
+ export function negotiateProtocol(requested: unknown): string {
96
+ return typeof requested === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(requested)
97
+ ? requested
98
+ : LATEST_PROTOCOL;
99
+ }
100
+
101
+ // --- response size guard -----------------------------------------------------
102
+ // Several tools returned unbounded payloads. On facebook/react (7091 files):
103
+ // graph 9.4 MB, symbols 6.3 MB, callers 6.0 MB, dead_code 771 KB — roughly
104
+ // 2.35M, 1.57M, 1.51M and 193k tokens. A single `graph` call does not merely
105
+ // bloat an agent's context, it exceeds what any MCP client can accept, so the
106
+ // call fails and the turn is wasted.
107
+ //
108
+ // The guard is deliberately NOT a default page size: below the limit a response
109
+ // is byte-identical to what it always was. Above it, the response could not be
110
+ // consumed by any client anyway, so replacing it with something actionable
111
+ // cannot regress a working call — it converts a hard failure into a usable
112
+ // answer that says how big the payload is, where the artifact already sits on
113
+ // disk, and which narrower tool answers the question.
114
+ export const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
115
+
116
+ // What to steer a caller toward when their whole-repo request is too large.
117
+ const NARROWER: Record<string, string> = {
118
+ graph: "pass `scope` to a subdirectory, or use repo_map / mermaid for an overview",
119
+ symbols: "pass `name` to look up one symbol, or use find_symbol / symbols_overview",
120
+ callers: "pass `name` to look up one symbol's call sites",
121
+ dead_code: "pass `scope` to a subdirectory",
122
+ find_references: "the symbol is referenced very widely — narrow with `scope` on a graph query",
123
+ check_rules: "narrow the rule set, or pass `scope` to a subdirectory",
124
+ };
125
+
126
+ // The persisted artifact backing a tool, when a `codeindex index` already wrote
127
+ // one — far more useful to hand back than a truncated blob.
128
+ const ARTIFACT_FOR: Record<string, string> = { graph: "graph.json", symbols: "symbols.json" };
129
+
130
+ export function capResponse(text: string, tool: string, repo: string, maxBytes: number): string {
131
+ const bytes = Buffer.byteLength(text, "utf8");
132
+ if (bytes <= maxBytes) return text;
133
+ const artifact = ARTIFACT_FOR[tool] ? join(repo, INDEX_DIR, ARTIFACT_FOR[tool]!) : undefined;
134
+ return (
135
+ JSON.stringify(
136
+ {
137
+ truncated: true,
138
+ tool,
139
+ bytes,
140
+ maxBytes,
141
+ reason:
142
+ "This response exceeds the configured limit and was withheld rather than sent as an unusable partial payload.",
143
+ narrower: NARROWER[tool] ?? "narrow the request with `scope`, `include`/`exclude`, or a `limit`",
144
+ ...(artifact && existsSync(artifact)
145
+ ? { artifact, artifactNote: "The full result is already on disk here — read it directly if you need all of it." }
146
+ : artifact
147
+ ? { artifactNote: `Run \`codeindex index --repo ${repo} --out ${join(repo, INDEX_DIR)}\` to get this as a file.` }
148
+ : {}),
149
+ },
150
+ null,
151
+ 2,
152
+ ) + "\n"
153
+ );
154
+ }
155
+
156
+ // When capResponse withheld a payload AND the artifact is on disk, hand the
157
+ // client a resource_link to it. Returns undefined for every normal response —
158
+ // this only ever adds a second content block to a capped one.
159
+ export function resourceLinkFor(text: string, tool: string): Record<string, unknown> | undefined {
160
+ const artifactName = ARTIFACT_FOR[tool];
161
+ if (!artifactName) return undefined;
162
+ let parsed: { truncated?: boolean; artifact?: string };
163
+ try {
164
+ parsed = JSON.parse(text) as typeof parsed;
165
+ } catch {
166
+ return undefined; // a normal (non-JSON, or non-capped) response
167
+ }
168
+ if (parsed.truncated !== true || typeof parsed.artifact !== "string") return undefined;
169
+ return {
170
+ type: "resource_link",
171
+ uri: pathToFileURL(parsed.artifact).href,
172
+ name: artifactName,
173
+ description: `The full ${tool} result this call was too large to inline.`,
174
+ mimeType: "application/json",
175
+ };
176
+ }
@@ -0,0 +1,287 @@
1
+ // Everything the long-lived server memoizes: the scan/artifacts LRU keyed on
2
+ // (repo, scan options), the embedding corpus index, the static embed model, and
3
+ // the per-call grammar warm.
4
+ //
5
+ // These are the pieces that make a second tool call cheap. They are stateful by
6
+ // nature — which is exactly why they belong in one file with the invariants
7
+ // written down, rather than scattered through the request handler.
8
+ import { statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { buildArtifactsFromScan, type IndexArtifacts } from "../pipeline.js";
11
+ import { scanRepo, scanSummary, type RepoScan, type ScanOptions, type ScanSummary } from "../scan.js";
12
+ import { preloadSession, toCacheMap, type PersistedCacheEntry, type PersistedCacheMap } from "../preload.js";
13
+ import { walk, type WalkResult } from "../walk.js";
14
+ import { ensureGrammars, grammarKeysForExts } from "../ast/loader.js";
15
+ import { resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "../embed/model.js";
16
+ import { type EmbeddingIndex } from "../embed/index.js";
17
+ import { sha1 } from "../hash.js";
18
+
19
+ // --- embedding index memoization --------------------------------------------
20
+ // The MCP server process is long-lived, but every `search` call used to redo
21
+ // the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
22
+ // or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
23
+ // repo changed between requests. Memoize the last build behind a fingerprint
24
+ // of the scan contents plus the tier's identity, so an unchanged repo reuses
25
+ // the cached index and any file add/edit/remove (or a switch of endpoint/model)
26
+ // still rebuilds. RepoScan carries no fingerprint of its own (checked
27
+ // scan.ts/types.ts) — every FileRecord already carries a content hash, so
28
+ // hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
29
+ export function scanFingerprint(scan: RepoScan): string {
30
+ return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
31
+ }
32
+
33
+ export interface EmbeddingIndexCacheKey {
34
+ mode: "endpoint" | "static";
35
+ // Distinguishes cache entries across configs sharing the same scan: the
36
+ // endpoint URL, or the model dir + modelId for the static tier.
37
+ identity: string;
38
+ scan: RepoScan;
39
+ }
40
+
41
+ // A SINGLE entry — never an unbounded map — holding the most recent build.
42
+ let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
43
+
44
+ // Reuse the cached index when (mode, identity, scanFingerprint) matches the
45
+ // last build; otherwise call `build` and cache its result. A failed build is
46
+ // NEVER cached (matches today's per-call error behavior: the next request
47
+ // retries from scratch, and a still-valid previous entry — under a different
48
+ // key — is left untouched).
49
+ export async function memoizedEmbeddingIndex(
50
+ key: EmbeddingIndexCacheKey,
51
+ build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
52
+ ): Promise<EmbeddingIndex> {
53
+ const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
54
+ if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
55
+ const index = await build();
56
+ embeddingIndexCache = { key: cacheKey, index };
57
+ return index;
58
+ }
59
+
60
+ // A SINGLE entry — never an unbounded map — holding the most recent parse.
61
+ let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
62
+
63
+ // model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
64
+ // EVERY request dominates static-tier latency, so the parsed model is memoized
65
+ // across requests. One statSync per request keys the cache on
66
+ // (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
67
+ // Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
68
+ // the throw propagates and the cache is left as it was, so the next request
69
+ // retries from scratch. A missing model.json returns undefined (the
70
+ // not-present case, exactly like loadEmbedModel).
71
+ export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
72
+ let stat;
73
+ try {
74
+ stat = statSync(join(modelDir, "model.json"));
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
79
+ if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
80
+ const model = loadEmbedModel(modelDir);
81
+ if (model) embedModelCache = { key, model };
82
+ return model;
83
+ }
84
+
85
+ // --- session-level scan + artifacts memoization ------------------------------
86
+ // Same single-entry discipline as the embedding caches above: the MCP server
87
+ // process is long-lived, but every tool call used to redo a FULL scanRepo walk
88
+ // + read + hash + extraction pass (and, for graph-shaped tools, the whole
89
+ // pipeline) even when nothing in the repo changed between requests. Cache the
90
+ // last (repo, scan-opts) scan and feed its records back to scanRepo as `cache`
91
+ // on the next call — scan.ts's EXISTING stat-fastpath + exact-hash machinery
92
+ // is the freshness oracle, so a cache hit costs one walk + per-file stats, not
93
+ // reads. When the oracle proves the content unchanged the SAME RepoScan object
94
+ // is returned, which keeps the per-scan WeakMap of derived structures
95
+ // (src/derived.ts) warm across requests. Artifacts are memoized on scan object
96
+ // identity. Rendered strings are NEVER memoized — a big repo's graph.json runs
97
+ // tens of MB, so renders stay per-call while the expensive structures behind
98
+ // them are reused.
99
+ //
100
+ // Determinism: reused records come from scan.ts's own reuse paths (stat
101
+ // fastpath / exact content-hash match), which produce records value-identical
102
+ // to a from-scratch scan — artifacts stay byte-identical; only repeated work
103
+ // disappears.
104
+
105
+ // The scan options a session entry is keyed on. `cache`/`precomputedWalk` are
106
+ // excluded from the contract: the session cache OWNS the cache it feeds back,
107
+ // and a caller-supplied stale walk would desynchronize the freshness oracle.
108
+ export type SessionScanOptions = Omit<ScanOptions, "cache" | "precomputedWalk">;
109
+
110
+ type SessionCacheEntry = PersistedCacheEntry;
111
+ type SessionCacheMap = PersistedCacheMap;
112
+
113
+ interface SessionEntry {
114
+ key: string;
115
+ scan: RepoScan;
116
+ cacheMap: SessionCacheMap;
117
+ arts?: IndexArtifacts;
118
+ }
119
+
120
+ // A SMALL bounded LRU — never an unbounded map.
121
+ //
122
+ // This was a single entry, which made two entirely normal agent behaviours
123
+ // pathological: alternating between two repos, and alternating between two
124
+ // `scope` values on one repo. Either one evicted the other on every call, so
125
+ // every call paid a full cold rebuild. Four entries covers those patterns while
126
+ // keeping the memory story the same order of magnitude as before.
127
+ const SESSION_CACHE_MAX = 4;
128
+ const sessionCaches: SessionEntry[] = []; // most-recently-used first
129
+
130
+ function sessionGet(key: string): SessionEntry | undefined {
131
+ const i = sessionCaches.findIndex((e) => e.key === key);
132
+ if (i < 0) return undefined;
133
+ const [entry] = sessionCaches.splice(i, 1);
134
+ sessionCaches.unshift(entry!);
135
+ return entry;
136
+ }
137
+
138
+ function sessionPut(entry: SessionEntry): SessionEntry {
139
+ const i = sessionCaches.findIndex((e) => e.key === entry.key);
140
+ if (i >= 0) sessionCaches.splice(i, 1);
141
+ sessionCaches.unshift(entry);
142
+ sessionCaches.length = Math.min(sessionCaches.length, SESSION_CACHE_MAX);
143
+ return entry;
144
+ }
145
+
146
+ // Drop every entry. Used by the symbolic-edit tools: an edit landing in the same
147
+ // mtime tick with the same byte count would pass the (size, mtimeMs) fastpath
148
+ // and serve a stale scan.
149
+ export function sessionClear(): void {
150
+ sessionCaches.length = 0;
151
+ }
152
+
153
+ // Fixed property order (and JSON.stringify dropping undefined) keeps the key
154
+ // deterministic regardless of how the caller assembled the options object.
155
+ export function sessionKey(repo: string, opts: SessionScanOptions): string {
156
+ return (
157
+ repo +
158
+ "\0" +
159
+ JSON.stringify({
160
+ scope: opts.scope,
161
+ include: opts.include,
162
+ exclude: opts.exclude,
163
+ gitignore: opts.gitignore,
164
+ ignoreDirs: opts.ignoreDirs,
165
+ maxBytes: opts.maxBytes,
166
+ maxFiles: opts.maxFiles,
167
+ maxCallsPerFile: opts.maxCallsPerFile,
168
+ out: opts.out,
169
+ fullHash: opts.fullHash,
170
+ })
171
+ );
172
+ }
173
+
174
+ // Re-exported for tests and for consumers that imported it from here before the
175
+ // preload machinery moved into src/preload.ts.
176
+ export { toCacheMap };
177
+
178
+ // The memoizing replacement for scanRepo inside callTool. Exported for tests.
179
+ export function getScan(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): RepoScan {
180
+ const key = sessionKey(repo, opts);
181
+ const hit = sessionGet(key);
182
+ if (hit) {
183
+ const fresh = scanRepo(repo, { ...opts, cache: hit.cacheMap, precomputedWalk: walked });
184
+ if (fresh.contentUnchanged) {
185
+ // Content proven identical → return the SAME object (object identity is
186
+ // what keeps derived.ts's WeakMap and the memoized artifacts warm). A
187
+ // stat-only drift (e.g. a bare touch) still refreshes the cache map so
188
+ // the next call's stat fastpath keys on the new (size, mtimeMs).
189
+ if (fresh.cacheDirty) hit.cacheMap = toCacheMap(fresh);
190
+ // `commit` (headCommit(root)) is NOT part of the stat/hash freshness
191
+ // oracle: a git HEAD move that leaves the worktree untouched — commit /
192
+ // commit --amend / reset --soft / checkout to an identical-tree branch —
193
+ // changes headCommit without altering any file's size or mtime, so
194
+ // contentUnchanged stays true while the cached scan's commit went stale.
195
+ // `fresh` recomputed it just now (exactly what a cold process reports), so
196
+ // sync it onto the returned object; otherwise scan_summary would emit the
197
+ // OLD commit a from-scratch scanRepo never would. Mutate the SAME object
198
+ // rather than clone — cloning would forfeit the identity the artifacts and
199
+ // derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
200
+ // symbols render byte-identically regardless), so nothing memoized here
201
+ // depends on this field.
202
+ if (hit.scan.commit !== fresh.commit) hit.scan.commit = fresh.commit;
203
+ return hit.scan;
204
+ }
205
+ sessionPut({ key, scan: fresh, cacheMap: toCacheMap(fresh) });
206
+ return fresh;
207
+ }
208
+ // First touch of this (repo, opts): try the persisted-index preload before a
209
+ // cold scan. A present, version-compatible .codeindex/cache.json seeds the
210
+ // scan (and, when the guard holds, the artifacts); absent it, fall through to
211
+ // the cold path EXACTLY as before.
212
+ const preloaded = preloadSession(repo, { ...opts, precomputedWalk: walked });
213
+ if (preloaded) {
214
+ sessionPut({ key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts });
215
+ return preloaded.scan;
216
+ }
217
+ const scan = scanRepo(repo, { ...opts, precomputedWalk: walked });
218
+ sessionPut({ key, scan, cacheMap: toCacheMap(scan) });
219
+ return scan;
220
+ }
221
+
222
+ // The scan_summary numbers, without paying for a scan.
223
+ //
224
+ // A file count and a language histogram come from the walk plus the path-based
225
+ // classifiers — no read, no hash, no tree-sitter. When this session already
226
+ // holds a scan for the same (repo, opts) we derive from it instead (identical
227
+ // numbers, and it keeps a warm session warm); otherwise scanSummary walks once.
228
+ // The summary is NEVER written into the session cache: it carries no
229
+ // FileRecords, so caching it would starve every record-shaped tool that ran next.
230
+ export function getScanSummary(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): ScanSummary {
231
+ if (sessionCaches.some((e) => e.key === sessionKey(repo, opts))) {
232
+ const scan = getScan(repo, opts, walked);
233
+ return {
234
+ root: scan.root,
235
+ commit: scan.commit,
236
+ fileCount: scan.files.length,
237
+ languages: scan.languages,
238
+ capped: scan.capped,
239
+ excluded: scan.excluded,
240
+ };
241
+ }
242
+ return scanSummary(repo, { ...opts, precomputedWalk: walked });
243
+ }
244
+
245
+ // Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
246
+ // artifacts exactly as long as getScan keeps returning the same scan object.
247
+ // Exported for tests.
248
+ export function getArtifacts(repo: string, opts: SessionScanOptions = {}, walked?: WalkResult): IndexArtifacts {
249
+ const scan = getScan(repo, opts, walked);
250
+ const entry = sessionCaches.find((e) => e.scan === scan);
251
+ if (entry) return (entry.arts ??= buildArtifactsFromScan(scan, opts));
252
+ // Defensive fallback (getScan always leaves an entry holding `scan`).
253
+ return buildArtifactsFromScan(scan, opts);
254
+ }
255
+
256
+ // Warm the grammars for the languages CURRENTLY present in `repo`, re-derived on
257
+ // EVERY scan-needing call — never frozen on first touch. The server no longer
258
+ // warms every committed grammar at startup; most sessions touch one repo and a
259
+ // handful of languages, so each scan-needing tool warms the walk-derived set
260
+ // itself. It MUST re-derive per call because the session cache (getScan) is built
261
+ // to pick up mid-session file adds/edits/removes: a language whose first file
262
+ // appears only AFTER the initial scan-needing call must still get its grammar
263
+ // warmed, or that file falls to the regex tier and its symbols diverge from a
264
+ // cold build on the identical on-disk state — a byte-identity break. (A per-
265
+ // repo-path memo froze the grammar set at first touch and silently missed
266
+ // exactly this case.) ensureGrammars is idempotent and near-free once a grammar
267
+ // is loaded — it warms only newly-seen keys — so the sole repeated cost is the
268
+ // walk; the wasm for a given language loads at most once. Determinism: the walk's
269
+ // extension set is a superset of what scanRepo keeps (scope/include/exclude only
270
+ // filter further), so every extracted file has its grammar loaded; Language.load
271
+ // calls are independent, so warming fewer grammars cannot alter the parse of a
272
+ // loaded one.
273
+ export async function warmGrammarsForRepo(repo: string): Promise<void> {
274
+ await warmGrammarsForWalk(walk(repo, {}));
275
+ }
276
+
277
+ // The same warm, against a walk the caller already has.
278
+ //
279
+ // callTool used to walk TWICE per scan-needing call: once here to derive the
280
+ // present languages, then again inside scanRepo. On a large repo that fixed cost
281
+ // dominated every response (the project's own benchmark shows find-symbol,
282
+ // references and file-overview all landing within a few ms of each other on a
283
+ // 20k-file monorepo — the signature of a per-call constant, not of the query).
284
+ // One walk now feeds both.
285
+ export async function warmGrammarsForWalk(walked: WalkResult): Promise<void> {
286
+ await ensureGrammars(grammarKeysForExts(walked.files.map((f) => f.ext)));
287
+ }