@maxgfr/codeindex 2.12.0 → 2.14.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/engine-cli.ts CHANGED
@@ -1,13 +1,21 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { SCHEMA_VERSION, EXTRACTOR_VERSION, type FileRecord } from "./types.js";
4
4
  import { ENGINE_VERSION } from "./types.js";
5
- import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
6
- import { buildIndexArtifacts, type BuildIndexOptions } from "./pipeline.js";
5
+ import { ensureGrammars, grammarKeysForExts, resolveGrammarsTier, sharedGrammarsCacheDir } from "./ast/loader.js";
6
+ import {
7
+ resolveGrammarsPullTarget,
8
+ fetchGrammarsTarball,
9
+ fetchExpectedSha256,
10
+ extractGrammarsTarball,
11
+ } from "./ast/grammars-pull.js";
12
+ import { buildIndexArtifacts, buildArtifactsFromScan, type BuildIndexOptions } from "./pipeline.js";
13
+ import { sha1 } from "./hash.js";
7
14
  import { renderGraphJson } from "./render/graph-json.js";
8
15
  import { renderSymbolsJson } from "./render/symbols-json.js";
9
16
  import { renderScip } from "./render/scip.js";
10
17
  import { scanRepo } from "./scan.js";
18
+ import { walk, type WalkResult } from "./walk.js";
11
19
  import { buildCallerIndex } from "./callers.js";
12
20
  import { detectWorkspaces } from "./workspaces.js";
13
21
  import { gitChurn } from "./git.js";
@@ -19,7 +27,7 @@ import { symbolComplexity, riskHotspots } from "./complexity.js";
19
27
  import { renderMermaid } from "./viz.js";
20
28
  import { searchIndex } from "./bm25.js";
21
29
  import { checkRules, parseRules } from "./rules.js";
22
- import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, resolveEmbedPullUrl, fetchEmbedModel } from "./embed/model.js";
30
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, parseEmbedModel, resolveEmbedPullUrl, fetchEmbedModel } from "./embed/model.js";
23
31
  import { buildEmbeddingIndex, serializeEmbeddings } from "./embed/index.js";
24
32
  import { searchSemantic } from "./embed/search.js";
25
33
  import {
@@ -60,6 +68,14 @@ Commands:
60
68
  the source with CODEINDEX_EMBED_URL
61
69
  embed serve Print (or --run) the docker command that starts the
62
70
  containerized embedding server (rich tier)
71
+ grammars Tree-sitter wasm grammars (optional AST tier; regex without them).
72
+ Precedence: bundle-adjacent > CODEINDEX_GRAMMARS_DIR > shared cache:
73
+ grammars status Active tier (adjacent/env/cache/none), resolved
74
+ dir, pinned ENGINE_VERSION, pull-needed (JSON)
75
+ grammars pull Fetch the per-release grammars-<version>.tar.gz
76
+ asset into the shared cache (sha256-verified,
77
+ atomic). Override the source with
78
+ CODEINDEX_GRAMMARS_URL
63
79
  rules Architecture rules (forbidden edges, cycles, orphans) validated
64
80
  against the link-graph: --config <codeindex.rules.json>; exits 1
65
81
  on any error-severity violation (a CI gate)
@@ -80,8 +96,11 @@ Flags:
80
96
  --exclude <glob> Exclude matching paths (repeatable)
81
97
  --scope <dir> Restrict to one directory (sugar for --include '<dir>/**')
82
98
  --no-gitignore Do not honor .gitignore files (default: honored)
99
+ --ignore-dir <name> Directory names to skip (repeatable) — REPLACES the
100
+ default ignored-directory set, never merges with it
83
101
  --max-files <n> Cap walked files (default 20000)
84
102
  --max-bytes <n> Skip files above this size (default 1 MiB)
103
+ --max-calls <n> Per-file call-site cap for extraction (default 512)
85
104
  --no-ast Skip tree-sitter grammars even when present (regex tier)
86
105
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
87
106
  --limit <n> Max results for \`search\` (default 20)
@@ -103,8 +122,10 @@ interface CliFlags {
103
122
  exclude: string[];
104
123
  scope?: string;
105
124
  gitignore: boolean;
125
+ ignoreDirs: string[];
106
126
  maxFiles?: number;
107
127
  maxBytes?: number;
128
+ maxCalls?: number;
108
129
  noAst: boolean;
109
130
  since?: string;
110
131
  ignoreCase?: boolean;
@@ -121,7 +142,7 @@ interface CliFlags {
121
142
  }
122
143
 
123
144
  function parseFlags(args: string[]): CliFlags {
124
- const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
145
+ const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
125
146
  for (let i = 0; i < args.length; i++) {
126
147
  const a = args[i]!;
127
148
  const next = (): string => {
@@ -144,8 +165,10 @@ function parseFlags(args: string[]): CliFlags {
144
165
  else if (a === "--exclude") flags.exclude.push(next());
145
166
  else if (a === "--scope") flags.scope = next();
146
167
  else if (a === "--no-gitignore") flags.gitignore = false;
168
+ else if (a === "--ignore-dir") flags.ignoreDirs.push(next());
147
169
  else if (a === "--max-files") flags.maxFiles = num();
148
170
  else if (a === "--max-bytes") flags.maxBytes = num();
171
+ else if (a === "--max-calls") flags.maxCalls = num();
149
172
  else if (a === "--ignore-case") flags.ignoreCase = true;
150
173
  else if (a === "--max-hits") flags.maxHits = num();
151
174
  else if (a === "--budget-tokens") flags.budgetTokens = num();
@@ -168,17 +191,32 @@ function emit(content: string, out?: string): void {
168
191
  else process.stdout.write(content);
169
192
  }
170
193
 
171
- function scanOptions(flags: CliFlags): BuildIndexOptions {
194
+ function scanOptions(flags: CliFlags, precomputedWalk?: WalkResult): BuildIndexOptions {
172
195
  return {
173
196
  include: flags.include.length ? flags.include : undefined,
174
197
  exclude: flags.exclude.length ? flags.exclude : undefined,
175
198
  scope: flags.scope,
176
199
  gitignore: flags.gitignore,
200
+ ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
177
201
  maxFiles: flags.maxFiles,
178
202
  maxBytes: flags.maxBytes,
203
+ maxCallsPerFile: flags.maxCalls,
204
+ // The walk performed once in runCli to warm the present-language grammars,
205
+ // reused here so scanRepo does not traverse the tree a second time. Absent
206
+ // for --no-ast / scan-less commands: scanRepo walks itself, unchanged.
207
+ precomputedWalk,
179
208
  };
180
209
  }
181
210
 
211
+ // Commands that never walk/scan the file tree — they read git/grep directly or
212
+ // only manage the grammar cache, so they must warm NO tree-sitter grammar (the
213
+ // CLI previously warmed every one unconditionally). `embed` is scan-only for its
214
+ // `build` subcommand; the other embed subcommands (status/pull/serve) are
215
+ // excluded by the positional check at the warm site. `grammars` (status/pull)
216
+ // resolves/downloads the wasms itself and must not warm them.
217
+ // version/help/mcp return before we get there.
218
+ const SCANLESS_COMMANDS = new Set(["grep", "churn", "coupling", "workspaces", "grammars"]);
219
+
182
220
  export async function runCli(argv: string[]): Promise<void> {
183
221
  const [cmd, ...rest] = argv;
184
222
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
@@ -197,7 +235,25 @@ export async function runCli(argv: string[]): Promise<void> {
197
235
 
198
236
  const flags = parseFlags(rest);
199
237
  if (!existsSync(flags.repo)) throw new Error(`--repo path does not exist: ${flags.repo}`);
200
- if (!flags.noAst) await ensureGrammars(allGrammarKeys());
238
+
239
+ // Warm ONLY the grammars for languages actually present, and only for commands
240
+ // that scan the file tree. Scan-less commands (grep, churn, coupling,
241
+ // workspaces, embed status|pull|serve) load no grammar at all; version/help/mcp
242
+ // already returned above. The walk is done ONCE here to derive the present
243
+ // extensions, then handed to the scan via precomputedWalk so the tree is
244
+ // traversed a single time. --no-ast keeps the regex tier: no walk, no warm —
245
+ // scanRepo walks itself, exactly as before.
246
+ const scans = !SCANLESS_COMMANDS.has(cmd) && !(cmd === "embed" && flags.positional !== "build");
247
+ let precomputedWalk: WalkResult | undefined;
248
+ if (scans && !flags.noAst) {
249
+ precomputedWalk = walk(flags.repo, {
250
+ maxFileBytes: flags.maxBytes,
251
+ maxFiles: flags.maxFiles,
252
+ gitignore: flags.gitignore,
253
+ ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
254
+ });
255
+ await ensureGrammars(grammarKeysForExts(precomputedWalk.files.map((f) => f.ext)));
256
+ }
201
257
 
202
258
  if (cmd === "index") {
203
259
  if (!flags.out) throw new Error("index needs --out <dir>");
@@ -206,47 +262,143 @@ export async function runCli(argv: string[]): Promise<void> {
206
262
  // Incremental cache: reuse per-file records when (schema, extractor) match —
207
263
  // same invalidation discipline as ultraindex's cache.json.
208
264
  const cachePath = join(outDir, "cache.json");
209
- let cache: Map<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }> | undefined;
265
+ type CacheEntry = { hash: string; record: FileRecord; size?: number; mtimeMs?: number };
266
+ // ADDITIVE meta keys describing the artifacts the cache-writing run put on
267
+ // disk. Old engines ignore them (they only check schema/extractor above);
268
+ // old caches lacking them simply never take the fastpath below (their
269
+ // per-file records are still reused). cache.json embeds mtimes, so it was
270
+ // never cross-machine byte-reproducible — no determinism surface changes.
271
+ type CacheMeta = {
272
+ engineVersion?: string;
273
+ commit?: string;
274
+ graphSha1?: string;
275
+ symbolsSha1?: string;
276
+ embed?: { embedVersion?: number; modelId?: string; sha1?: string };
277
+ };
278
+ let cache: Map<string, CacheEntry> | undefined;
279
+ let meta: CacheMeta = {};
210
280
  try {
211
281
  const parsed = JSON.parse(readFileSync(cachePath, "utf8")) as {
212
282
  schemaVersion: number;
213
283
  extractorVersion: number;
214
- files: Record<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }>;
215
- };
284
+ files: Record<string, CacheEntry>;
285
+ } & CacheMeta;
216
286
  if (parsed.schemaVersion === SCHEMA_VERSION && parsed.extractorVersion === EXTRACTOR_VERSION) {
217
287
  cache = new Map(Object.entries(parsed.files));
288
+ meta = {
289
+ engineVersion: parsed.engineVersion,
290
+ commit: parsed.commit,
291
+ graphSha1: parsed.graphSha1,
292
+ symbolsSha1: parsed.symbolsSha1,
293
+ embed: parsed.embed,
294
+ };
218
295
  }
219
296
  } catch {
220
297
  // no cache yet (or unreadable) — cold build
221
298
  }
222
- const { scan, graph, symbols } = buildIndexArtifacts(flags.repo, { ...scanOptions(flags), cache, out: outDir });
223
- writeFileSync(join(outDir, "graph.json"), renderGraphJson(graph));
224
- writeFileSync(join(outDir, "symbols.json"), renderSymbolsJson(symbols));
225
- const files: Record<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }> = {};
226
- for (const f of scan.files) {
227
- const entry: { hash: string; record: FileRecord; size?: number; mtimeMs?: number } = { hash: f.hash, record: f, size: f.size };
228
- const mtime = scan.mtimes.get(f.rel);
229
- if (mtime !== undefined) entry.mtimeMs = mtime;
230
- files[f.rel] = entry;
231
- }
232
- writeFileSync(
233
- cachePath,
234
- JSON.stringify({ schemaVersion: SCHEMA_VERSION, extractorVersion: EXTRACTOR_VERSION, files }) + "\n",
235
- );
236
- // Deterministic embeddings sidecar: written next to graph.json ONLY when a
237
- // model asset is present (opt-in). Silently skipped otherwise — no model, no
238
- // embeddings.bin, no impact on the graph/symbols consumers.
239
- let embedNote = "";
299
+ const scan = scanRepo(flags.repo, { ...scanOptions(flags, precomputedWalk), cache, out: outDir });
240
300
  const modelDir = resolveEmbedModelDir(flags.repo);
241
301
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
242
- if (model) {
243
- const index = buildEmbeddingIndex(scan, model);
244
- writeFileSync(join(outDir, "embeddings.bin"), serializeEmbeddings(index));
245
- embedNote = ` + embeddings.bin (${index.records.length} records, model ${model.modelId})`;
302
+
303
+ const graphPath = join(outDir, "graph.json");
304
+ const symbolsPath = join(outDir, "symbols.json");
305
+ const embedPath = join(outDir, "embeddings.bin");
306
+ // sha of an on-disk artifact, or undefined when it is missing/unreadable —
307
+ // never equal to a defined meta sha, so a deleted artifact fails the guard.
308
+ const artifactSha = (path: string): string | undefined => {
309
+ try {
310
+ return sha1(readFileSync(path));
311
+ } catch {
312
+ return undefined;
313
+ }
314
+ };
315
+ const writeCache = (out: Pick<CacheMeta, "graphSha1" | "symbolsSha1" | "embed">): void => {
316
+ const files: Record<string, CacheEntry> = {};
317
+ for (const f of scan.files) {
318
+ const entry: CacheEntry = { hash: f.hash, record: f, size: f.size };
319
+ const mtime = scan.mtimes.get(f.rel);
320
+ if (mtime !== undefined) entry.mtimeMs = mtime;
321
+ files[f.rel] = entry;
322
+ }
323
+ // Fixed key order; JSON.stringify drops the undefined-valued keys
324
+ // (commit outside a git worktree, embed without a model) cleanly.
325
+ writeFileSync(
326
+ cachePath,
327
+ JSON.stringify({
328
+ schemaVersion: SCHEMA_VERSION,
329
+ extractorVersion: EXTRACTOR_VERSION,
330
+ engineVersion: ENGINE_VERSION,
331
+ commit: scan.commit,
332
+ graphSha1: out.graphSha1,
333
+ symbolsSha1: out.symbolsSha1,
334
+ embed: out.embed,
335
+ files,
336
+ }) + "\n",
337
+ );
338
+ };
339
+
340
+ // FASTPATH GUARD — skip the whole downstream pipeline only when this scan
341
+ // is proven identical to the run that wrote the on-disk artifacts:
342
+ // contentUnchanged means this scan's records are object-identical to that
343
+ // run's; downstream is a pure function of (records, docText, commit,
344
+ // meta-opts) and the CLI never sets meta/previousCommunities;
345
+ // engineVersion pins the version stamp; commit must match because
346
+ // graph.json embeds it (identical trees under a new HEAD must rebuild);
347
+ // the shas prove the on-disk bytes are that run's output. ANY failure —
348
+ // deleted or tampered artifacts included — falls through to the full
349
+ // build, which rewrites everything (self-healing).
350
+ const embedUnchanged =
351
+ !model ||
352
+ (meta.embed !== undefined &&
353
+ meta.embed.embedVersion === EMBED_VERSION &&
354
+ meta.embed.modelId === model.modelId &&
355
+ meta.embed.sha1 !== undefined &&
356
+ artifactSha(embedPath) === meta.embed.sha1);
357
+ const fastpath =
358
+ scan.contentUnchanged &&
359
+ meta.engineVersion === ENGINE_VERSION &&
360
+ meta.commit === scan.commit &&
361
+ meta.graphSha1 !== undefined &&
362
+ artifactSha(graphPath) === meta.graphSha1 &&
363
+ meta.symbolsSha1 !== undefined &&
364
+ artifactSha(symbolsPath) === meta.symbolsSha1 &&
365
+ embedUnchanged;
366
+
367
+ if (fastpath) {
368
+ // Artifacts verified byte-identical to what this build would produce —
369
+ // leave them untouched. Rewrite cache.json only when the scan says its
370
+ // bytes would change (e.g. an mtime drifted); the meta is carried
371
+ // forward verbatim since the guard just proved it describes the disk.
372
+ if (scan.cacheDirty) writeCache(meta);
373
+ process.stderr.write(
374
+ `codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${scan.capped ? " (capped)" : ""} (unchanged — artifacts reused)\n`,
375
+ );
376
+ } else {
377
+ const { graph, symbols } = buildArtifactsFromScan(scan);
378
+ const graphJson = renderGraphJson(graph);
379
+ const symbolsJson = renderSymbolsJson(symbols);
380
+ writeFileSync(graphPath, graphJson);
381
+ writeFileSync(symbolsPath, symbolsJson);
382
+ // Deterministic embeddings sidecar: written next to graph.json ONLY when a
383
+ // model asset is present (opt-in). Silently skipped otherwise — no model, no
384
+ // embeddings.bin, no impact on the graph/symbols consumers.
385
+ let embedNote = "";
386
+ let embedMeta: CacheMeta["embed"];
387
+ if (model) {
388
+ const index = buildEmbeddingIndex(scan, model);
389
+ const bytes = serializeEmbeddings(index);
390
+ writeFileSync(embedPath, bytes);
391
+ embedMeta = { embedVersion: EMBED_VERSION, modelId: model.modelId, sha1: sha1(bytes) };
392
+ embedNote = ` + embeddings.bin (${index.records.length} records, model ${model.modelId})`;
393
+ }
394
+ // cache.json is written LAST so its meta always describes artifacts that
395
+ // are already on disk — a crash mid-way leaves stale meta whose shas
396
+ // fail the guard on the next run (safe: it just rebuilds).
397
+ writeCache({ graphSha1: sha1(graphJson), symbolsSha1: sha1(symbolsJson), embed: embedMeta });
398
+ process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
246
399
  }
247
- process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
248
400
  } else if (cmd === "scan") {
249
- const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags));
401
+ const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
250
402
  const summary = {
251
403
  engineVersion: ENGINE_VERSION,
252
404
  commit: scan.commit,
@@ -256,13 +408,13 @@ export async function runCli(argv: string[]): Promise<void> {
256
408
  };
257
409
  emit(JSON.stringify(summary, null, 2) + "\n", flags.out);
258
410
  } else if (cmd === "graph") {
259
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
411
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
260
412
  emit(renderGraphJson(graph), flags.out);
261
413
  } else if (cmd === "symbols") {
262
- const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags));
414
+ const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
263
415
  emit(renderSymbolsJson(symbols), flags.out);
264
416
  } else if (cmd === "scip") {
265
- const scan = scanRepo(flags.repo, scanOptions(flags));
417
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
266
418
  const bytes = renderScip(scan, { projectRoot: flags.projectRoot });
267
419
  const out = flags.out ?? resolve("index.scip");
268
420
  if (out === "-") process.stdout.write(Buffer.from(bytes));
@@ -271,14 +423,14 @@ export async function runCli(argv: string[]): Promise<void> {
271
423
  process.stderr.write(`codeindex: SCIP index → ${out} (${bytes.length} bytes)\n`);
272
424
  }
273
425
  } else if (cmd === "callers") {
274
- const scan = scanRepo(flags.repo, scanOptions(flags));
426
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
275
427
  const index = buildCallerIndex(scan, undefined, { recall: flags.recall });
276
428
  const obj: Record<string, unknown> = {};
277
429
  for (const [name, entry] of index) obj[name] = entry;
278
430
  emit(JSON.stringify(obj, null, 2) + "\n", flags.out);
279
431
  } else if (cmd === "search") {
280
432
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
281
- const scan = scanRepo(flags.repo, scanOptions(flags));
433
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
282
434
  if (flags.semantic) {
283
435
  const endpoint = resolveEmbedEndpoint();
284
436
  const lexical = (): void => {
@@ -383,7 +535,7 @@ export async function runCli(argv: string[]): Promise<void> {
383
535
  }
384
536
  const model = loadEmbedModel(modelDir)!;
385
537
  mkdirSync(flags.out, { recursive: true });
386
- const scan = scanRepo(flags.repo, scanOptions(flags));
538
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
387
539
  const index = buildEmbeddingIndex(scan, model);
388
540
  writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
389
541
  process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
@@ -404,9 +556,14 @@ export async function runCli(argv: string[]): Promise<void> {
404
556
  return;
405
557
  }
406
558
  try {
407
- JSON.parse(body);
408
- } catch {
409
- process.stderr.write("codeindex: pull failed response is not a valid model.json (expected JSON)\n");
559
+ // Shape-validate BEFORE writing: a JSON-valid but shape-invalid asset
560
+ // would otherwise land on disk and turn every later semantic search
561
+ // into a hard loadEmbedModel error instead of the documented degrade.
562
+ parseEmbedModel(JSON.parse(body), url);
563
+ } catch (e) {
564
+ process.stderr.write(
565
+ `codeindex: pull failed — response is not a valid model.json (${e instanceof Error ? e.message : String(e)}) (nothing written)\n`,
566
+ );
410
567
  process.exitCode = 1;
411
568
  return;
412
569
  }
@@ -415,10 +572,109 @@ export async function runCli(argv: string[]): Promise<void> {
415
572
  } else {
416
573
  throw new Error("embed needs a subcommand: status | build | pull | serve");
417
574
  }
575
+ } else if (cmd === "grammars") {
576
+ const sub = flags.positional;
577
+ const cacheDir = sharedGrammarsCacheDir();
578
+ if (sub === "status") {
579
+ // Report which tier furnishes the wasms (adjacent/env/cache/none), the
580
+ // resolved dir, the pinned ENGINE_VERSION the cache is keyed on, and
581
+ // whether a pull is needed (no runtime wasm resolvable → AST off, regex).
582
+ const info = resolveGrammarsTier();
583
+ const runtimePresent = info.dir ? existsSync(join(info.dir, "web-tree-sitter.wasm")) : false;
584
+ const target = resolveGrammarsPullTarget();
585
+ const status: Record<string, unknown> = {
586
+ engineVersion: ENGINE_VERSION,
587
+ tier: info.tier,
588
+ dir: info.dir ?? null,
589
+ cacheDir,
590
+ runtimePresent,
591
+ pullNeeded: !runtimePresent,
592
+ url: target.url,
593
+ };
594
+ emit(JSON.stringify(status, null, 2) + "\n", flags.out);
595
+ } else if (sub === "pull") {
596
+ // Default: the official per-release asset + its `.sha256` sidecar. A
597
+ // user-set CODEINDEX_GRAMMARS_URL overrides both (mirror/custom, no
598
+ // verification). Fetch the expected digest from the sidecar first; a
599
+ // missing sidecar degrades to an unverified pull with a note (never fatal).
600
+ const target = resolveGrammarsPullTarget();
601
+ let expected: string | undefined;
602
+ if (target.sha256Url) {
603
+ try {
604
+ expected = await fetchExpectedSha256(target.sha256Url);
605
+ } catch (e) {
606
+ process.stderr.write(
607
+ `codeindex: could not fetch checksum (${e instanceof Error ? e.message : String(e)}) — proceeding unverified\n`,
608
+ );
609
+ }
610
+ }
611
+ // Idempotent: the marker sibling records the digest of the tarball that
612
+ // populated cacheDir. If the runtime wasm is present AND the marker matches
613
+ // the freshly-fetched digest, the cache is already up to date — skip the
614
+ // ~22 MB download entirely. (Keeps cacheDir itself byte-identical to the
615
+ // tarball; the marker lives next to it, never inside it.)
616
+ const runtime = join(cacheDir, "web-tree-sitter.wasm");
617
+ const markerPath = join(dirname(cacheDir), `${ENGINE_VERSION}.sha256`);
618
+ if (existsSync(runtime) && expected && existsSync(markerPath)) {
619
+ let marker = "";
620
+ try {
621
+ marker = readFileSync(markerPath, "utf8").trim();
622
+ } catch {
623
+ // unreadable marker → fall through and re-pull
624
+ }
625
+ if (marker === expected) {
626
+ process.stderr.write(`codeindex: grammars already present at ${cacheDir} (up to date)\n`);
627
+ return;
628
+ }
629
+ }
630
+ process.stderr.write(`codeindex: fetching grammars from ${target.url} → ${cacheDir}\n`);
631
+ let bytes: Uint8Array;
632
+ try {
633
+ // Follows redirects (GitHub → CDN) and verifies sha256 when known.
634
+ bytes = await fetchGrammarsTarball(target.url, expected);
635
+ } catch (e) {
636
+ process.stderr.write(`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`);
637
+ process.exitCode = 1;
638
+ return;
639
+ }
640
+ // Atomic install: extract into a tmp dir SIBLING to cacheDir (same
641
+ // filesystem → rename is atomic), sanity-check the runtime wasm landed,
642
+ // then swap it into place. A failure at any step discards the tmp dir and
643
+ // leaves any existing cache untouched — a half-populated cache never shows.
644
+ let tmp: string | undefined;
645
+ try {
646
+ mkdirSync(dirname(cacheDir), { recursive: true });
647
+ tmp = mkdtempSync(join(dirname(cacheDir), ".grammars-tmp-"));
648
+ extractGrammarsTarball(bytes, tmp);
649
+ if (!existsSync(join(tmp, "web-tree-sitter.wasm"))) {
650
+ throw new Error("archive is missing web-tree-sitter.wasm");
651
+ }
652
+ if (existsSync(cacheDir)) rmSync(cacheDir, { recursive: true, force: true });
653
+ renameSync(tmp, cacheDir);
654
+ tmp = undefined;
655
+ if (expected) writeFileSync(markerPath, expected + "\n");
656
+ } catch (e) {
657
+ if (tmp) {
658
+ try {
659
+ rmSync(tmp, { recursive: true, force: true });
660
+ } catch {
661
+ // best-effort cleanup
662
+ }
663
+ }
664
+ process.stderr.write(
665
+ `codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`,
666
+ );
667
+ process.exitCode = 1;
668
+ return;
669
+ }
670
+ process.stderr.write(`codeindex: grammars extracted → ${cacheDir}\n`);
671
+ } else {
672
+ throw new Error("grammars needs a subcommand: status | pull");
673
+ }
418
674
  } else if (cmd === "rules") {
419
675
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
420
676
  const rules = parseRules(JSON.parse(readFileSync(flags.config, "utf8")));
421
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
677
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
422
678
  const violations = checkRules(graph, rules);
423
679
  const errors = violations.filter((v) => v.severity === "error").length;
424
680
  emit(JSON.stringify({ errors, warnings: violations.length - errors, violations }, null, 2) + "\n", flags.out);
@@ -439,26 +695,26 @@ export async function runCli(argv: string[]): Promise<void> {
439
695
  for (const k of [...churn.keys()].sort()) sorted[k] = churn.get(k)!;
440
696
  emit(JSON.stringify({ ok, churn: sorted }, null, 2) + "\n", flags.out);
441
697
  } else if (cmd === "repomap") {
442
- const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
698
+ const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
443
699
  emit(renderRepoMap(scan, graph, { budgetTokens: flags.budgetTokens }), flags.out);
444
700
  } else if (cmd === "hotspots") {
445
- const scan = scanRepo(flags.repo, scanOptions(flags));
701
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
446
702
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
447
703
  emit(JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2) + "\n", flags.out);
448
704
  } else if (cmd === "coupling") {
449
705
  const { ok, couplings } = changeCoupling(flags.repo, { since: flags.since });
450
706
  emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags.out);
451
707
  } else if (cmd === "deadcode") {
452
- emit(JSON.stringify(findDeadCode(scanRepo(flags.repo, scanOptions(flags))), null, 2) + "\n", flags.out);
708
+ emit(JSON.stringify(findDeadCode(scanRepo(flags.repo, scanOptions(flags, precomputedWalk))), null, 2) + "\n", flags.out);
453
709
  } else if (cmd === "complexity") {
454
- const scan = scanRepo(flags.repo, scanOptions(flags));
710
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
455
711
  emit(JSON.stringify(symbolComplexity(scan, flags.positional), null, 2) + "\n", flags.out);
456
712
  } else if (cmd === "risk") {
457
- const scan = scanRepo(flags.repo, scanOptions(flags));
713
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
458
714
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
459
715
  emit(JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2) + "\n", flags.out);
460
716
  } else if (cmd === "mermaid") {
461
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
717
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
462
718
  emit(renderMermaid(graph, { module: flags.positional }), flags.out);
463
719
  } else if (cmd === "grep") {
464
720
  if (!flags.positional) throw new Error("grep needs a pattern: cli.mjs grep <pattern> --repo <dir>");
package/src/engine.ts CHANGED
@@ -45,8 +45,23 @@ export { extractMarkdown } from "./extract/markdown.js";
45
45
  export type { MarkdownInfo } from "./extract/markdown.js";
46
46
 
47
47
  // AST tier (optional — a no-op without the grammar wasm sidecar).
48
- export { ensureGrammars, allGrammarKeys, grammarKeyForExt, grammarReady } from "./ast/loader.js";
48
+ export { ensureGrammars, allGrammarKeys, grammarKeysForExts, grammarKeyForExt, grammarReady } from "./ast/loader.js";
49
49
  export { extractAst } from "./ast/extract.js";
50
+ // Grammars resolution + the slim pull/cache tier (v2.14.0): resolve the wasm
51
+ // dir across bundle-adjacent → env → shared cache → none, and fetch the
52
+ // committed wasms into the shared cache when none ship next to the bundle
53
+ // (`codeindex grammars pull|status`). Additive; opt-in; offline-safe.
54
+ export { resolveGrammarsDir, resolveGrammarsTier, sharedGrammarsCacheDir } from "./ast/loader.js";
55
+ export type { GrammarsTier, GrammarsTierName } from "./ast/loader.js";
56
+ export {
57
+ DEFAULT_GRAMMARS_URL,
58
+ resolveGrammarsPullTarget,
59
+ fetchGrammarsTarball,
60
+ fetchExpectedSha256,
61
+ extractTarInto,
62
+ extractGrammarsTarball,
63
+ } from "./ast/grammars-pull.js";
64
+ export type { GrammarsPullTarget } from "./ast/grammars-pull.js";
50
65
 
51
66
  // Resolution + modules + graph tier.
52
67
  export { buildResolveContext, resolveImport, resolveDocLink } from "./resolve.js";
@@ -78,8 +93,9 @@ export { renderGraphJson } from "./render/graph-json.js";
78
93
  export { renderScip } from "./render/scip.js";
79
94
  export type { RenderScipOptions } from "./render/scip.js";
80
95
 
81
- // One-call pipeline.
82
- export { buildIndexArtifacts } from "./pipeline.js";
96
+ // One-call pipeline (buildArtifactsFromScan: the scan-onward half, for callers
97
+ // that already hold a RepoScan).
98
+ export { buildIndexArtifacts, buildArtifactsFromScan } from "./pipeline.js";
83
99
  export type { BuildIndexOptions, IndexArtifacts } from "./pipeline.js";
84
100
 
85
101
  // Git utilities.
@@ -114,7 +130,7 @@ export {
114
130
  loadEmbedModel,
115
131
  resolveEmbedPullUrl,
116
132
  } from "./embed/model.js";
117
- export type { StaticEmbedModel } from "./embed/model.js";
133
+ export type { StaticEmbedModel, EmbedPullTarget } from "./embed/model.js";
118
134
  export { encode, quantize, tokenize, wordpiece, basicTokenize, roundHalfToEven, intDot } from "./embed/encode.js";
119
135
  export { buildEmbeddingIndex, serializeEmbeddings, deserializeEmbeddings, embeddingUnits } from "./embed/index.js";
120
136
  export type { EmbeddingIndex, EmbeddingRecord, EmbeddingUnit } from "./embed/index.js";
@@ -161,6 +177,7 @@ export type { RepoMapOptions } from "./repomap.js";
161
177
 
162
178
  // MCP server over stdio (also reachable as `engine.mjs mcp`).
163
179
  export { runMcpServer } from "./mcp.js";
180
+ export type { McpServerOptions } from "./mcp.js";
164
181
 
165
182
  // General-purpose helpers shared by consumers (deterministic, dependency-free).
166
183
  export { sha1, shortHash } from "./hash.js";
@@ -302,12 +302,13 @@ const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bm
302
302
  export function collectCallsRegex(
303
303
  content: string,
304
304
  symbols: Pick<CodeSymbol, "name" | "line">[] = [],
305
+ maxCalls: number = 512,
305
306
  ): { name: string; line: number; receiver?: string }[] {
306
307
  const out = new Map<string, { name: string; line: number; receiver?: string }>();
307
308
  const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
308
309
  const lines = content.split("\n");
309
310
  const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
310
- for (let i = 0; i < lines.length && out.size < 512; i++) {
311
+ for (let i = 0; i < lines.length && out.size < maxCalls; i++) {
311
312
  const line = lines[i]!;
312
313
  // Cheap comment guard: a line-leading comment marker means no calls here
313
314
  // (block-comment interiors and strings stay best-effort, like the symbol
@@ -332,7 +333,7 @@ export function collectCallsRegex(
332
333
  CALL_RE.lastIndex = 0;
333
334
  let m: RegExpExecArray | null;
334
335
  const fallbackExcluded = new Set<string>();
335
- while ((m = CALL_RE.exec(line)) !== null && out.size < 512) {
336
+ while ((m = CALL_RE.exec(line)) !== null && out.size < maxCalls) {
336
337
  const receiver = m[1];
337
338
  const name = m[2]!;
338
339
  if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
@@ -350,13 +351,16 @@ export function collectCallsRegex(
350
351
  return [...out.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.line - b.line));
351
352
  }
352
353
 
353
- export function extractCode(rel: string, ext: string, content: string): CodeInfo {
354
+ // `opts.maxCallsPerFile` overrides the per-file call-site cap (default 512) on
355
+ // BOTH extraction tiers — AST and regex — so recall-oriented consumers can raise
356
+ // it. Dedup/sort semantics are unchanged; absent, output is byte-identical.
357
+ export function extractCode(rel: string, ext: string, content: string, opts: { maxCallsPerFile?: number } = {}): CodeInfo {
354
358
  // Symbols come from tree-sitter when a grammar is loaded for this extension
355
359
  // (AST-exact: real nesting, precise kinds, structural export), else the regex
356
360
  // extractors. Imports/pkg stay on the battle-tested regex path here — their
357
361
  // resolution is covered by resolve tests and the e2e ratchet; the new-language
358
362
  // AST importers land with their resolvers.
359
- const ast = extractAst(rel, ext, content);
363
+ const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile });
360
364
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
361
365
  // Add barrel re-exports the local def didn't already cover.
362
366
  const known = new Set(symbols.map((s) => s.name));
@@ -378,7 +382,7 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
378
382
  // collector otherwise, so caller indexes exist without the wasm sidecar.
379
383
  // `symbols` (this file's own regex-extracted defs) lets the collector
380
384
  // exclude a definition's own name+line from its call candidates.
381
- calls: ast ? ast.calls : collectCallsRegex(content, symbols),
385
+ calls: ast ? ast.calls : collectCallsRegex(content, symbols, opts.maxCallsPerFile),
382
386
  importedNames: ast?.importedNames,
383
387
  };
384
388
  }
package/src/hash.ts CHANGED
@@ -2,8 +2,11 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  // Stable content hash used for the manifest's staleness oracle and for the
4
4
  // `ui:gen` region fingerprints. sha1 is plenty for change detection (not
5
- // security) and keeps the manifest compact.
6
- export function sha1(s: string): string {
5
+ // security) and keeps the manifest compact. Accepts raw bytes too (additive)
6
+ // so binary artifacts (e.g. embeddings.bin) hash without a lossy decode; a
7
+ // string is hashed as its UTF-8 bytes — identical to hashing the bytes
8
+ // writeFileSync would put on disk for that string.
9
+ export function sha1(s: string | Uint8Array): string {
7
10
  return createHash("sha1").update(s).digest("hex");
8
11
  }
9
12
 
package/src/lang/js-ts.ts CHANGED
@@ -7,7 +7,7 @@ import { scan, type Rule } from "./common.js";
7
7
  const RULES: Rule[] = [
8
8
  { re: /^\s*export\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
9
9
  { re: /^\s*export\s+default\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
10
- { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
10
+ { re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?!extends\b)(?<name>[\w$]+)/, kind: "class", exported: true },
11
11
  { re: /^\s*(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: false },
12
12
  { re: /^\s*export\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
13
13
  { re: /^\s*(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: false },