@maxgfr/codeindex 2.13.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";
@@ -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)
@@ -175,7 +191,7 @@ function emit(content: string, out?: string): void {
175
191
  else process.stdout.write(content);
176
192
  }
177
193
 
178
- function scanOptions(flags: CliFlags): BuildIndexOptions {
194
+ function scanOptions(flags: CliFlags, precomputedWalk?: WalkResult): BuildIndexOptions {
179
195
  return {
180
196
  include: flags.include.length ? flags.include : undefined,
181
197
  exclude: flags.exclude.length ? flags.exclude : undefined,
@@ -185,9 +201,22 @@ function scanOptions(flags: CliFlags): BuildIndexOptions {
185
201
  maxFiles: flags.maxFiles,
186
202
  maxBytes: flags.maxBytes,
187
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,
188
208
  };
189
209
  }
190
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
+
191
220
  export async function runCli(argv: string[]): Promise<void> {
192
221
  const [cmd, ...rest] = argv;
193
222
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
@@ -206,7 +235,25 @@ export async function runCli(argv: string[]): Promise<void> {
206
235
 
207
236
  const flags = parseFlags(rest);
208
237
  if (!existsSync(flags.repo)) throw new Error(`--repo path does not exist: ${flags.repo}`);
209
- 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
+ }
210
257
 
211
258
  if (cmd === "index") {
212
259
  if (!flags.out) throw new Error("index needs --out <dir>");
@@ -215,47 +262,143 @@ export async function runCli(argv: string[]): Promise<void> {
215
262
  // Incremental cache: reuse per-file records when (schema, extractor) match —
216
263
  // same invalidation discipline as ultraindex's cache.json.
217
264
  const cachePath = join(outDir, "cache.json");
218
- 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 = {};
219
280
  try {
220
281
  const parsed = JSON.parse(readFileSync(cachePath, "utf8")) as {
221
282
  schemaVersion: number;
222
283
  extractorVersion: number;
223
- files: Record<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }>;
224
- };
284
+ files: Record<string, CacheEntry>;
285
+ } & CacheMeta;
225
286
  if (parsed.schemaVersion === SCHEMA_VERSION && parsed.extractorVersion === EXTRACTOR_VERSION) {
226
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
+ };
227
295
  }
228
296
  } catch {
229
297
  // no cache yet (or unreadable) — cold build
230
298
  }
231
- const { scan, graph, symbols } = buildIndexArtifacts(flags.repo, { ...scanOptions(flags), cache, out: outDir });
232
- writeFileSync(join(outDir, "graph.json"), renderGraphJson(graph));
233
- writeFileSync(join(outDir, "symbols.json"), renderSymbolsJson(symbols));
234
- const files: Record<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }> = {};
235
- for (const f of scan.files) {
236
- const entry: { hash: string; record: FileRecord; size?: number; mtimeMs?: number } = { hash: f.hash, record: f, size: f.size };
237
- const mtime = scan.mtimes.get(f.rel);
238
- if (mtime !== undefined) entry.mtimeMs = mtime;
239
- files[f.rel] = entry;
240
- }
241
- writeFileSync(
242
- cachePath,
243
- JSON.stringify({ schemaVersion: SCHEMA_VERSION, extractorVersion: EXTRACTOR_VERSION, files }) + "\n",
244
- );
245
- // Deterministic embeddings sidecar: written next to graph.json ONLY when a
246
- // model asset is present (opt-in). Silently skipped otherwise — no model, no
247
- // embeddings.bin, no impact on the graph/symbols consumers.
248
- let embedNote = "";
299
+ const scan = scanRepo(flags.repo, { ...scanOptions(flags, precomputedWalk), cache, out: outDir });
249
300
  const modelDir = resolveEmbedModelDir(flags.repo);
250
301
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
251
- if (model) {
252
- const index = buildEmbeddingIndex(scan, model);
253
- writeFileSync(join(outDir, "embeddings.bin"), serializeEmbeddings(index));
254
- 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`);
255
399
  }
256
- process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
257
400
  } else if (cmd === "scan") {
258
- const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags));
401
+ const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
259
402
  const summary = {
260
403
  engineVersion: ENGINE_VERSION,
261
404
  commit: scan.commit,
@@ -265,13 +408,13 @@ export async function runCli(argv: string[]): Promise<void> {
265
408
  };
266
409
  emit(JSON.stringify(summary, null, 2) + "\n", flags.out);
267
410
  } else if (cmd === "graph") {
268
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
411
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
269
412
  emit(renderGraphJson(graph), flags.out);
270
413
  } else if (cmd === "symbols") {
271
- const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags));
414
+ const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
272
415
  emit(renderSymbolsJson(symbols), flags.out);
273
416
  } else if (cmd === "scip") {
274
- const scan = scanRepo(flags.repo, scanOptions(flags));
417
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
275
418
  const bytes = renderScip(scan, { projectRoot: flags.projectRoot });
276
419
  const out = flags.out ?? resolve("index.scip");
277
420
  if (out === "-") process.stdout.write(Buffer.from(bytes));
@@ -280,14 +423,14 @@ export async function runCli(argv: string[]): Promise<void> {
280
423
  process.stderr.write(`codeindex: SCIP index → ${out} (${bytes.length} bytes)\n`);
281
424
  }
282
425
  } else if (cmd === "callers") {
283
- const scan = scanRepo(flags.repo, scanOptions(flags));
426
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
284
427
  const index = buildCallerIndex(scan, undefined, { recall: flags.recall });
285
428
  const obj: Record<string, unknown> = {};
286
429
  for (const [name, entry] of index) obj[name] = entry;
287
430
  emit(JSON.stringify(obj, null, 2) + "\n", flags.out);
288
431
  } else if (cmd === "search") {
289
432
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
290
- const scan = scanRepo(flags.repo, scanOptions(flags));
433
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
291
434
  if (flags.semantic) {
292
435
  const endpoint = resolveEmbedEndpoint();
293
436
  const lexical = (): void => {
@@ -392,7 +535,7 @@ export async function runCli(argv: string[]): Promise<void> {
392
535
  }
393
536
  const model = loadEmbedModel(modelDir)!;
394
537
  mkdirSync(flags.out, { recursive: true });
395
- const scan = scanRepo(flags.repo, scanOptions(flags));
538
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
396
539
  const index = buildEmbeddingIndex(scan, model);
397
540
  writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
398
541
  process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
@@ -429,10 +572,109 @@ export async function runCli(argv: string[]): Promise<void> {
429
572
  } else {
430
573
  throw new Error("embed needs a subcommand: status | build | pull | serve");
431
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
+ }
432
674
  } else if (cmd === "rules") {
433
675
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
434
676
  const rules = parseRules(JSON.parse(readFileSync(flags.config, "utf8")));
435
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
677
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
436
678
  const violations = checkRules(graph, rules);
437
679
  const errors = violations.filter((v) => v.severity === "error").length;
438
680
  emit(JSON.stringify({ errors, warnings: violations.length - errors, violations }, null, 2) + "\n", flags.out);
@@ -453,26 +695,26 @@ export async function runCli(argv: string[]): Promise<void> {
453
695
  for (const k of [...churn.keys()].sort()) sorted[k] = churn.get(k)!;
454
696
  emit(JSON.stringify({ ok, churn: sorted }, null, 2) + "\n", flags.out);
455
697
  } else if (cmd === "repomap") {
456
- const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
698
+ const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
457
699
  emit(renderRepoMap(scan, graph, { budgetTokens: flags.budgetTokens }), flags.out);
458
700
  } else if (cmd === "hotspots") {
459
- const scan = scanRepo(flags.repo, scanOptions(flags));
701
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
460
702
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
461
703
  emit(JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2) + "\n", flags.out);
462
704
  } else if (cmd === "coupling") {
463
705
  const { ok, couplings } = changeCoupling(flags.repo, { since: flags.since });
464
706
  emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags.out);
465
707
  } else if (cmd === "deadcode") {
466
- 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);
467
709
  } else if (cmd === "complexity") {
468
- const scan = scanRepo(flags.repo, scanOptions(flags));
710
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
469
711
  emit(JSON.stringify(symbolComplexity(scan, flags.positional), null, 2) + "\n", flags.out);
470
712
  } else if (cmd === "risk") {
471
- const scan = scanRepo(flags.repo, scanOptions(flags));
713
+ const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
472
714
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
473
715
  emit(JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2) + "\n", flags.out);
474
716
  } else if (cmd === "mermaid") {
475
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
717
+ const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
476
718
  emit(renderMermaid(graph, { module: flags.positional }), flags.out);
477
719
  } else if (cmd === "grep") {
478
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.
@@ -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";
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