@maxgfr/codeindex 2.13.0 → 2.15.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/mcp.ts CHANGED
@@ -5,14 +5,15 @@
5
5
  // `repo` path and returns JSON text content.
6
6
  //
7
7
  // Register in an MCP client as: node scripts/engine.mjs mcp
8
- import { statSync } from "node:fs";
8
+ import { readFileSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
- import { ENGINE_VERSION } from "./types.js";
12
- import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
13
- import { buildIndexArtifacts } from "./pipeline.js";
11
+ import { ENGINE_VERSION, SCHEMA_VERSION, EXTRACTOR_VERSION, type FileRecord, type Graph, type SymbolIndex } from "./types.js";
12
+ import { ensureGrammars, grammarKeysForExts } from "./ast/loader.js";
13
+ import { buildArtifactsFromScan, type IndexArtifacts } from "./pipeline.js";
14
14
  import { renderGraphJson } from "./render/graph-json.js";
15
- import { scanRepo, type RepoScan } from "./scan.js";
15
+ import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
16
+ import { walk } from "./walk.js";
16
17
  import { buildCallerIndex } from "./callers.js";
17
18
  import { detectWorkspaces } from "./workspaces.js";
18
19
  import { gitChurn } from "./git.js";
@@ -393,13 +394,289 @@ export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefin
393
394
  return model;
394
395
  }
395
396
 
397
+ // --- session-level scan + artifacts memoization ------------------------------
398
+ // Same single-entry discipline as the embedding caches above: the MCP server
399
+ // process is long-lived, but every tool call used to redo a FULL scanRepo walk
400
+ // + read + hash + extraction pass (and, for graph-shaped tools, the whole
401
+ // pipeline) even when nothing in the repo changed between requests. Cache the
402
+ // last (repo, scan-opts) scan and feed its records back to scanRepo as `cache`
403
+ // on the next call — scan.ts's EXISTING stat-fastpath + exact-hash machinery
404
+ // is the freshness oracle, so a cache hit costs one walk + per-file stats, not
405
+ // reads. When the oracle proves the content unchanged the SAME RepoScan object
406
+ // is returned, which keeps the per-scan WeakMap of derived structures
407
+ // (src/derived.ts) warm across requests. Artifacts are memoized on scan object
408
+ // identity. Rendered strings are NEVER memoized — a big repo's graph.json runs
409
+ // tens of MB, so renders stay per-call while the expensive structures behind
410
+ // them are reused.
411
+ //
412
+ // Determinism: reused records come from scan.ts's own reuse paths (stat
413
+ // fastpath / exact content-hash match), which produce records value-identical
414
+ // to a from-scratch scan — artifacts stay byte-identical; only repeated work
415
+ // disappears.
416
+
417
+ // The scan options a session entry is keyed on. `cache`/`precomputedWalk` are
418
+ // excluded from the contract: the session cache OWNS the cache it feeds back,
419
+ // and a caller-supplied stale walk would desynchronize the freshness oracle.
420
+ export type SessionScanOptions = Omit<ScanOptions, "cache" | "precomputedWalk">;
421
+
422
+ type SessionCacheEntry = { hash: string; record: FileRecord; size?: number; mtimeMs?: number };
423
+ type SessionCacheMap = Map<string, SessionCacheEntry>;
424
+
425
+ // A SINGLE entry — never an unbounded map — holding the most recent scan.
426
+ let sessionCache:
427
+ | { key: string; scan: RepoScan; cacheMap: SessionCacheMap; arts?: IndexArtifacts }
428
+ | undefined;
429
+
430
+ // Fixed property order (and JSON.stringify dropping undefined) keeps the key
431
+ // deterministic regardless of how the caller assembled the options object.
432
+ function sessionKey(repo: string, opts: SessionScanOptions): string {
433
+ return (
434
+ repo +
435
+ "\0" +
436
+ JSON.stringify({
437
+ scope: opts.scope,
438
+ include: opts.include,
439
+ exclude: opts.exclude,
440
+ gitignore: opts.gitignore,
441
+ ignoreDirs: opts.ignoreDirs,
442
+ maxBytes: opts.maxBytes,
443
+ maxFiles: opts.maxFiles,
444
+ maxCallsPerFile: opts.maxCallsPerFile,
445
+ out: opts.out,
446
+ fullHash: opts.fullHash,
447
+ })
448
+ );
449
+ }
450
+
451
+ // A scan re-expressed as the `ScanOptions.cache` shape (the exact map the CLI
452
+ // persists as cache.json): rel → (hash, record, size, mtimeMs), so the next
453
+ // scanRepo can take the stat fastpath / hash-match reuse paths against it.
454
+ // Exported for tests.
455
+ export function toCacheMap(scan: RepoScan): SessionCacheMap {
456
+ const m: SessionCacheMap = new Map();
457
+ for (const f of scan.files) m.set(f.rel, { hash: f.hash, record: f, size: f.size, mtimeMs: scan.mtimes.get(f.rel) });
458
+ return m;
459
+ }
460
+
461
+ // --- persisted-index preload -------------------------------------------------
462
+ // On the FIRST tool call for a repo, a committed .codeindex/ index (written by
463
+ // `codeindex index`) lets the session skip work TWO ways. cache.json seeds the
464
+ // session scan, so every unchanged file takes scan.ts's stat fastpath instead
465
+ // of a read + hash + extraction; and — only when the T4 freshness guard holds —
466
+ // the persisted graph.json/symbols.json are deserialized straight into the
467
+ // session, so the first graph/symbols/mermaid/repo_map/check_rules call skips
468
+ // the whole downstream pipeline (buildArtifactsFromScan). Both are pure
469
+ // optimizations: the seeded scan's reused records are value-identical to a cold
470
+ // scan's (T3/T4 determinism), and the guard is the SAME oracle the CLI's index
471
+ // fastpath uses to prove the on-disk artifacts equal a fresh build here. Absent,
472
+ // stale, corrupt, or any version/commit/sha mismatch → every step falls back to
473
+ // today's cold path EXACTLY (a fresh scanRepo / buildArtifactsFromScan), never a
474
+ // throw.
475
+
476
+ // ADDITIVE cache.json meta describing the artifacts a prior `index` run wrote
477
+ // (see engine-cli.ts's CacheMeta). Old caches lacking these keys simply never
478
+ // pass the guard below — their per-file records are still reused to seed the
479
+ // scan. Only the graph/symbols shas matter here; the embed sidecar has its own
480
+ // memoization path.
481
+ interface PersistedMeta {
482
+ engineVersion?: string;
483
+ commit?: string;
484
+ graphSha1?: string;
485
+ symbolsSha1?: string;
486
+ }
487
+
488
+ // Read <repo>/.codeindex/cache.json into the (cacheMap, meta) the preload needs.
489
+ // Per-file records are reusable ONLY when (schemaVersion, extractorVersion)
490
+ // match this engine — the exact gate the CLI applies before trusting a cache —
491
+ // otherwise the whole cache is discarded (cold scan). Any read/parse failure (no
492
+ // index yet, unreadable, malformed) returns undefined: the cold path.
493
+ function readPersistedIndex(repo: string): { cacheMap: SessionCacheMap; meta: PersistedMeta } | undefined {
494
+ let parsed:
495
+ | ({ schemaVersion?: number; extractorVersion?: number; files?: Record<string, SessionCacheEntry> } & PersistedMeta)
496
+ | undefined;
497
+ try {
498
+ parsed = JSON.parse(readFileSync(join(repo, ".codeindex", "cache.json"), "utf8")) as typeof parsed;
499
+ } catch {
500
+ return undefined;
501
+ }
502
+ if (!parsed || parsed.schemaVersion !== SCHEMA_VERSION || parsed.extractorVersion !== EXTRACTOR_VERSION || !parsed.files) {
503
+ return undefined;
504
+ }
505
+ const cacheMap: SessionCacheMap = new Map(Object.entries(parsed.files));
506
+ const meta: PersistedMeta = {
507
+ engineVersion: parsed.engineVersion,
508
+ commit: parsed.commit,
509
+ graphSha1: parsed.graphSha1,
510
+ symbolsSha1: parsed.symbolsSha1,
511
+ };
512
+ return { cacheMap, meta };
513
+ }
514
+
515
+ // The T4 freshness guard, applied to a session scan seeded from cache.json:
516
+ // contentUnchanged proves this scan's records are the ones that built the
517
+ // on-disk artifacts; engineVersion pins the version stamp graph.json embeds and
518
+ // commit the HEAD it embeds; the sha checks prove the on-disk bytes ARE that
519
+ // build's output. All true ⇒ graph.json/symbols.json are byte-equal to
520
+ // buildArtifactsFromScan(scan) run here, so deserialize them instead of
521
+ // rebuilding. Graph/SymbolIndex are pure JSON POJOs (no Map/Set/typed fields),
522
+ // so JSON.parse is a lossless round-trip — a schemaVersion assert is the only
523
+ // reconstruction needed (see the round-trip test). ANY failure — a stale scan,
524
+ // a version/commit/sha mismatch, a missing/corrupt/partial artifact, an
525
+ // unexpected schemaVersion — returns undefined so the caller rebuilds. NEVER
526
+ // throws (a corrupt artifact must degrade, not crash the session).
527
+ function preloadArtifacts(repo: string, scan: RepoScan, meta: PersistedMeta): IndexArtifacts | undefined {
528
+ if (
529
+ !scan.contentUnchanged ||
530
+ meta.engineVersion !== ENGINE_VERSION ||
531
+ meta.commit !== scan.commit ||
532
+ meta.graphSha1 === undefined ||
533
+ meta.symbolsSha1 === undefined
534
+ ) {
535
+ return undefined;
536
+ }
537
+ const dir = join(repo, ".codeindex");
538
+ let graphBytes: Buffer;
539
+ let symbolsBytes: Buffer;
540
+ try {
541
+ graphBytes = readFileSync(join(dir, "graph.json"));
542
+ symbolsBytes = readFileSync(join(dir, "symbols.json"));
543
+ } catch {
544
+ return undefined; // a sha'd artifact went missing since cache.json — rebuild
545
+ }
546
+ // sha over the raw bytes; sha1(string) hashes the same UTF-8 bytes writeFileSync
547
+ // put on disk, so this equals the meta sha the CLI computed over the render.
548
+ if (sha1(graphBytes) !== meta.graphSha1 || sha1(symbolsBytes) !== meta.symbolsSha1) {
549
+ return undefined; // tampered / partial / corrupt on-disk bytes — rebuild
550
+ }
551
+ try {
552
+ const graph = JSON.parse(graphBytes.toString("utf8")) as Graph;
553
+ const symbols = JSON.parse(symbolsBytes.toString("utf8")) as SymbolIndex;
554
+ if (graph.schemaVersion !== SCHEMA_VERSION || symbols.schemaVersion !== SCHEMA_VERSION) return undefined;
555
+ return { scan, graph, symbols };
556
+ } catch {
557
+ // Unreachable once the shas matched (the bytes are valid JSON this engine
558
+ // wrote), but the contract is "never throw" — degrade to a rebuild.
559
+ return undefined;
560
+ }
561
+ }
562
+
563
+ // First-touch preload: seed the session scan from cache.json and, when the guard
564
+ // holds, the artifacts from graph.json/symbols.json. undefined ⇒ no persisted
565
+ // index ⇒ the caller takes the cold scanRepo path unchanged.
566
+ function preloadSession(
567
+ repo: string,
568
+ opts: SessionScanOptions,
569
+ ): { scan: RepoScan; cacheMap: SessionCacheMap; arts?: IndexArtifacts } | undefined {
570
+ const persisted = readPersistedIndex(repo);
571
+ if (!persisted) return undefined;
572
+ // Seed the scan from the persisted records — scan.ts's stat fastpath + exact
573
+ // content-hash reuse make this value-identical to a cold scan (T3/T4), only
574
+ // cheaper, and it computes the contentUnchanged the artifact guard reads. When
575
+ // the on-disk content drifted from cache.json, changed files are re-read/
576
+ // extracted here exactly as a cold scan would, so the scan stays correct and
577
+ // the guard simply fails (arts undefined → rebuild on demand).
578
+ const scan = scanRepo(repo, { ...opts, cache: persisted.cacheMap });
579
+ const arts = preloadArtifacts(repo, scan, persisted.meta);
580
+ return { scan, cacheMap: toCacheMap(scan), arts };
581
+ }
582
+
583
+ // The memoizing replacement for scanRepo inside callTool. Exported for tests.
584
+ export function getScan(repo: string, opts: SessionScanOptions = {}): RepoScan {
585
+ const key = sessionKey(repo, opts);
586
+ if (sessionCache && sessionCache.key === key) {
587
+ const fresh = scanRepo(repo, { ...opts, cache: sessionCache.cacheMap });
588
+ if (fresh.contentUnchanged) {
589
+ // Content proven identical → return the SAME object (object identity is
590
+ // what keeps derived.ts's WeakMap and the memoized artifacts warm). A
591
+ // stat-only drift (e.g. a bare touch) still refreshes the cache map so
592
+ // the next call's stat fastpath keys on the new (size, mtimeMs).
593
+ if (fresh.cacheDirty) sessionCache.cacheMap = toCacheMap(fresh);
594
+ // `commit` (headCommit(root)) is NOT part of the stat/hash freshness
595
+ // oracle: a git HEAD move that leaves the worktree untouched — commit /
596
+ // commit --amend / reset --soft / checkout to an identical-tree branch —
597
+ // changes headCommit without altering any file's size or mtime, so
598
+ // contentUnchanged stays true while the cached scan's commit went stale.
599
+ // `fresh` recomputed it just now (exactly what a cold process reports), so
600
+ // sync it onto the returned object; otherwise scan_summary would emit the
601
+ // OLD commit a from-scratch scanRepo never would. Mutate the SAME object
602
+ // rather than clone — cloning would forfeit the identity the artifacts and
603
+ // derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
604
+ // symbols render byte-identically regardless), so nothing memoized here
605
+ // depends on this field.
606
+ if (sessionCache.scan.commit !== fresh.commit) sessionCache.scan.commit = fresh.commit;
607
+ return sessionCache.scan;
608
+ }
609
+ sessionCache = { key, scan: fresh, cacheMap: toCacheMap(fresh) };
610
+ return fresh;
611
+ }
612
+ // First touch of this (repo, opts): try the persisted-index preload before a
613
+ // cold scan. A present, version-compatible .codeindex/cache.json seeds the
614
+ // scan (and, when the guard holds, the artifacts); absent it, fall through to
615
+ // the cold path EXACTLY as before.
616
+ const preloaded = preloadSession(repo, opts);
617
+ if (preloaded) {
618
+ sessionCache = { key, scan: preloaded.scan, cacheMap: preloaded.cacheMap, arts: preloaded.arts };
619
+ return preloaded.scan;
620
+ }
621
+ const scan = scanRepo(repo, opts);
622
+ sessionCache = { key, scan, cacheMap: toCacheMap(scan) };
623
+ return scan;
624
+ }
625
+
626
+ // Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
627
+ // artifacts exactly as long as getScan keeps returning the same scan object.
628
+ // Exported for tests.
629
+ export function getArtifacts(repo: string, opts: SessionScanOptions = {}): IndexArtifacts {
630
+ const scan = getScan(repo, opts);
631
+ if (sessionCache && sessionCache.scan === scan) {
632
+ return (sessionCache.arts ??= buildArtifactsFromScan(scan, opts));
633
+ }
634
+ // Defensive fallback (getScan always leaves sessionCache holding `scan`).
635
+ return buildArtifactsFromScan(scan, opts);
636
+ }
637
+
638
+ // Warm the grammars for the languages CURRENTLY present in `repo`, re-derived on
639
+ // EVERY scan-needing call — never frozen on first touch. The server no longer
640
+ // warms every committed grammar at startup; most sessions touch one repo and a
641
+ // handful of languages, so each scan-needing tool warms the walk-derived set
642
+ // itself. It MUST re-derive per call because the session cache (getScan) is built
643
+ // to pick up mid-session file adds/edits/removes: a language whose first file
644
+ // appears only AFTER the initial scan-needing call must still get its grammar
645
+ // warmed, or that file falls to the regex tier and its symbols diverge from a
646
+ // cold build on the identical on-disk state — a byte-identity break. (A per-
647
+ // repo-path memo froze the grammar set at first touch and silently missed
648
+ // exactly this case.) ensureGrammars is idempotent and near-free once a grammar
649
+ // is loaded — it warms only newly-seen keys — so the sole repeated cost is the
650
+ // walk; the wasm for a given language loads at most once. Determinism: the walk's
651
+ // extension set is a superset of what scanRepo keeps (scope/include/exclude only
652
+ // filter further), so every extracted file has its grammar loaded; Language.load
653
+ // calls are independent, so warming fewer grammars cannot alter the parse of a
654
+ // loaded one.
655
+ export async function warmGrammarsForRepo(repo: string): Promise<void> {
656
+ const { files } = walk(repo, {});
657
+ await ensureGrammars(grammarKeysForExts(files.map((f) => f.ext)));
658
+ }
659
+
660
+ // Tools that never scan the file tree (git/grep/memory/embed-status only) — they
661
+ // must not trigger a grammar warm. Every other tool is scan-needing and warms
662
+ // the repo's grammars first; defaulting to "warm" keeps a newly added scan tool
663
+ // correct without having to be listed here.
664
+ const SCANLESS_TOOLS = new Set([
665
+ "workspaces", "churn", "coupling", "grep",
666
+ "write_memory", "read_memory", "list_memories", "delete_memory",
667
+ "embed_status",
668
+ ]);
669
+
396
670
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
397
671
  const repo = str(args.repo);
398
672
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
399
673
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
674
+ // Scan-needing tools warm the present-language grammars (re-derived per call)
675
+ // before any scan so extraction takes the AST tier; scan-less tools skip it.
676
+ if (!SCANLESS_TOOLS.has(name)) await warmGrammarsForRepo(repo);
400
677
 
401
678
  if (name === "scan_summary") {
402
- const scan = scanRepo(repo, scanOpts);
679
+ const scan = getScan(repo, scanOpts);
403
680
  return JSON.stringify(
404
681
  { engineVersion: ENGINE_VERSION, commit: scan.commit, fileCount: scan.files.length, languages: scan.languages, capped: scan.capped },
405
682
  null,
@@ -407,10 +684,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
407
684
  );
408
685
  }
409
686
  if (name === "graph") {
410
- return renderGraphJson(buildIndexArtifacts(repo, scanOpts).graph);
687
+ return renderGraphJson(getArtifacts(repo, scanOpts).graph);
411
688
  }
412
689
  if (name === "symbols") {
413
- const { symbols } = buildIndexArtifacts(repo, scanOpts);
690
+ const { symbols } = getArtifacts(repo, scanOpts);
414
691
  const lookup = str(args.name);
415
692
  if (lookup) {
416
693
  return JSON.stringify({ name: lookup, defs: symbols.defs[lookup] ?? [], refs: symbols.refs[lookup] ?? [] }, null, 2);
@@ -418,7 +695,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
418
695
  return JSON.stringify(symbols, null, 2);
419
696
  }
420
697
  if (name === "callers") {
421
- const index = buildCallerIndex(scanRepo(repo, scanOpts));
698
+ const index = buildCallerIndex(getScan(repo, scanOpts));
422
699
  const lookup = str(args.name);
423
700
  if (lookup) {
424
701
  const entry = index.get(lookup);
@@ -441,12 +718,12 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
441
718
  if (name === "symbols_overview") {
442
719
  const file = str(args.file);
443
720
  if (!file) throw new Error("`file` is required");
444
- return JSON.stringify(symbolsOverview(scanRepo(repo, scanOpts), file), null, 2);
721
+ return JSON.stringify(symbolsOverview(getScan(repo, scanOpts), file), null, 2);
445
722
  }
446
723
  if (name === "find_symbol") {
447
724
  const namePath = str(args.namePath);
448
725
  if (!namePath) throw new Error("`namePath` is required");
449
- const matches = findSymbol(scanRepo(repo, scanOpts), namePath, {
726
+ const matches = findSymbol(getScan(repo, scanOpts), namePath, {
450
727
  substring: args.substring === true,
451
728
  includeBody: args.includeBody === true,
452
729
  });
@@ -455,15 +732,23 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
455
732
  if (name === "find_references") {
456
733
  const symName = str(args.name);
457
734
  if (!symName) throw new Error("`name` is required");
458
- return JSON.stringify(findReferences(scanRepo(repo, scanOpts), symName), null, 2);
735
+ return JSON.stringify(findReferences(getScan(repo, scanOpts), symName), null, 2);
459
736
  }
460
737
  if (name === "replace_symbol_body" || name === "insert_after_symbol" || name === "insert_before_symbol") {
461
738
  const namePath = str(args.namePath);
462
739
  const body = typeof args.body === "string" ? args.body : undefined;
463
740
  if (!namePath || body === undefined) throw new Error("`namePath` and `body` are required");
464
- const scan = scanRepo(repo, scanOpts);
741
+ const scan = getScan(repo, scanOpts);
465
742
  const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
466
- return JSON.stringify(fn(scan, namePath, body, str(args.file)), null, 2);
743
+ const result = fn(scan, namePath, body, str(args.file));
744
+ // A write WE just performed must not be trusted to the stat oracle: an
745
+ // edit landing in the same mtime tick with the same byte count would pass
746
+ // the (size, mtimeMs) fastpath and serve a stale scan. Drop the whole
747
+ // session entry unconditionally — the next call rescans from scratch.
748
+ // (write_memory needs no invalidation: .codeindex/ is excluded from the
749
+ // walk, so memories never enter a scan.)
750
+ sessionCache = undefined;
751
+ return JSON.stringify(result, null, 2);
467
752
  }
468
753
  if (name === "write_memory") {
469
754
  const memName = str(args.name);
@@ -487,10 +772,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
487
772
  return JSON.stringify({ deleted: deleteMemory(repo, memName) }, null, 2);
488
773
  }
489
774
  if (name === "dead_code") {
490
- return JSON.stringify(findDeadCode(scanRepo(repo, scanOpts)), null, 2);
775
+ return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
491
776
  }
492
777
  if (name === "complexity") {
493
- const scan = scanRepo(repo, scanOpts);
778
+ const scan = getScan(repo, scanOpts);
494
779
  if (args.risk === true) {
495
780
  const { churn, ok } = gitChurn(repo);
496
781
  return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2);
@@ -498,15 +783,15 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
498
783
  return JSON.stringify(symbolComplexity(scan, str(args.file)), null, 2);
499
784
  }
500
785
  if (name === "mermaid") {
501
- const { graph } = buildIndexArtifacts(repo, scanOpts);
786
+ const { graph } = getArtifacts(repo, scanOpts);
502
787
  return renderMermaid(graph, { module: str(args.module) });
503
788
  }
504
789
  if (name === "repo_map") {
505
- const { scan, graph } = buildIndexArtifacts(repo, scanOpts);
790
+ const { scan, graph } = getArtifacts(repo, scanOpts);
506
791
  return renderRepoMap(scan, graph, { budgetTokens: typeof args.budgetTokens === "number" ? args.budgetTokens : undefined });
507
792
  }
508
793
  if (name === "hotspots") {
509
- const scan = scanRepo(repo, scanOpts);
794
+ const scan = getScan(repo, scanOpts);
510
795
  const { churn, ok } = gitChurn(repo, { since: str(args.since) });
511
796
  return JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2);
512
797
  }
@@ -527,7 +812,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
527
812
  if (name === "search") {
528
813
  const query = str(args.query);
529
814
  if (!query) throw new Error("`query` is required");
530
- const scan = scanRepo(repo, scanOpts);
815
+ const scan = getScan(repo, scanOpts);
531
816
  const limit = typeof args.limit === "number" ? args.limit : undefined;
532
817
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
533
818
  if (args.semantic === true) {
@@ -595,14 +880,28 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
595
880
  }
596
881
  if (name === "check_rules") {
597
882
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
598
- const { graph } = buildIndexArtifacts(repo, scanOpts);
883
+ const { graph } = getArtifacts(repo, scanOpts);
599
884
  return JSON.stringify(checkRules(graph, rules), null, 2);
600
885
  }
601
886
  throw new Error(`unknown tool: ${name}`);
602
887
  }
603
888
 
604
- export async function runMcpServer(): Promise<void> {
605
- await ensureGrammars(allGrammarKeys()); // AST tier when the sidecar is present
889
+ export interface McpServerOptions {
890
+ // Override the serverInfo announced in the initialize response — for
891
+ // downstream consumers embedding this server under their own identity.
892
+ // Omitted fields keep the defaults (name "codeindex", ENGINE_VERSION).
893
+ serverInfo?: { name?: string; version?: string };
894
+ }
895
+
896
+ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
897
+ const serverInfo = {
898
+ name: opts.serverInfo?.name ?? "codeindex",
899
+ version: opts.serverInfo?.version ?? ENGINE_VERSION,
900
+ };
901
+ // No startup warm: each scan-needing tool warms the present-language grammars
902
+ // for its repo before it runs (warmGrammarsForRepo re-derives them per call),
903
+ // so a session that never scans — or only touches one language — loads no
904
+ // unused wasm, and a language first seen mid-session still gets warmed.
606
905
 
607
906
  const send = (msg: Record<string, unknown>): void => {
608
907
  process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
@@ -635,7 +934,7 @@ export async function runMcpServer(): Promise<void> {
635
934
  result: {
636
935
  protocolVersion: "2024-11-05",
637
936
  capabilities: { tools: {} },
638
- serverInfo: { name: "codeindex", version: ENGINE_VERSION },
937
+ serverInfo,
639
938
  },
640
939
  });
641
940
  } else if (req.method === "ping") {
package/src/pipeline.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import type { Graph, SymbolIndex } from "./types.js";
2
2
  import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
3
- import { buildResolveContext } from "./resolve.js";
3
+ import { resolveContextFor, symbolRefsFor } from "./derived.js";
4
4
  import { buildModules } from "./modules.js";
5
5
  import { buildGraph } from "./graph.js";
6
6
  import { detectCommunities } from "./community.js";
7
7
  import { applyCentrality } from "./centrality.js";
8
8
  import { computeTestMap } from "./tests-map.js";
9
9
  import { computeSurprises } from "./surprise.js";
10
- import { buildSymbolIndex, computeSymbolRefs } from "./render/symbols-json.js";
10
+ import { buildSymbolIndex } from "./render/symbols-json.js";
11
11
 
12
12
  export interface BuildIndexOptions extends ScanOptions {
13
13
  // Stamped into the graph a consumer persists — lets it carry its own
@@ -29,8 +29,19 @@ export interface IndexArtifacts {
29
29
  // exact composition (and mutation order — it matters for byte-stable output)
30
30
  // that ultraindex's build performs before its prose rendering.
31
31
  export function buildIndexArtifacts(repo: string, opts: BuildIndexOptions = {}): IndexArtifacts {
32
- const scan = scanRepo(repo, opts);
33
- const ctx = buildResolveContext(scan);
32
+ return buildArtifactsFromScan(scanRepo(repo, opts), opts);
33
+ }
34
+
35
+ // Everything downstream of the scan: resolve → group → graph → communities →
36
+ // centrality → tests-map → surprises → symbol index, in the same (load-bearing)
37
+ // mutation order as buildIndexArtifacts — which is now just scanRepo + this.
38
+ // Lets a caller that already holds a RepoScan build the artifacts without
39
+ // re-walking the repo.
40
+ export function buildArtifactsFromScan(scan: RepoScan, opts: BuildIndexOptions = {}): IndexArtifacts {
41
+ // Per-scan cache accessors (src/derived.ts): identical values to the direct
42
+ // builders, and building here PRE-WARMS the WeakMap so later queries on this
43
+ // same scan object (findReferences, findDeadCode, …) reuse the work.
44
+ const ctx = resolveContextFor(scan);
34
45
  const { modules, moduleOf } = buildModules(scan);
35
46
  const graph = buildGraph(scan, ctx, modules, moduleOf, opts.meta);
36
47
 
@@ -54,6 +65,6 @@ export function buildIndexArtifacts(repo: string, opts: BuildIndexOptions = {}):
54
65
  const surprises = computeSurprises(graph);
55
66
  if (surprises.length) graph.surprises = surprises;
56
67
 
57
- const symbols = buildSymbolIndex(scan, computeSymbolRefs(scan));
68
+ const symbols = buildSymbolIndex(scan, symbolRefsFor(scan));
58
69
  return { scan, graph, symbols };
59
70
  }
package/src/query.ts CHANGED
@@ -8,8 +8,8 @@ import { join } from "node:path";
8
8
  import type { CodeSymbol } from "./types.js";
9
9
  import type { RepoScan } from "./scan.js";
10
10
  import { readText } from "./walk.js";
11
- import { buildCallerIndex, type CallerSite } from "./callers.js";
12
- import { uniqueSymbolDefs } from "./graph.js";
11
+ import type { CallerSite } from "./callers.js";
12
+ import { callerIndexFor, uniqueDefsFor } from "./derived.js";
13
13
  import { byStr } from "./sort.js";
14
14
 
15
15
  const REFERENCE_KINDS = new Set(["reexport", "reexport-all", "default"]);
@@ -95,12 +95,15 @@ export function findReferences(scan: RepoScan, name: string): SymbolReferences {
95
95
  }
96
96
  defs.sort((a, b) => byStr(a.file, b.file) || a.line - b.line);
97
97
 
98
- const index = buildCallerIndex(scan);
98
+ const index = callerIndexFor(scan);
99
99
  const entry = index.get(name);
100
- const callSites = entry ? entry.callers : [];
100
+ // COPY, not alias: the caller index is memoized per scan (src/derived.ts),
101
+ // so handing out the cached array would let a consumer mutation poison
102
+ // every later findReferences on this scan.
103
+ const callSites = entry ? [...entry.callers] : [];
101
104
 
102
105
  const referencingFiles = new Set<string>();
103
- const unique = uniqueSymbolDefs(scan);
106
+ const unique = uniqueDefsFor(scan);
104
107
  const defFile = unique.get(name);
105
108
  for (const f of scan.files) {
106
109
  if (f.rel === defFile) continue;
package/src/scan.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import type { FileRecord } from "./types.js";
3
- import { walk, readText } from "./walk.js";
3
+ import { walk, readText, type WalkResult } from "./walk.js";
4
4
  import { headCommit } from "./git.js";
5
5
  import { sha1 } from "./hash.js";
6
6
  import { classify, MARKDOWN_EXT } from "./classify.js";
@@ -27,6 +27,20 @@ export interface RepoScan {
27
27
  // rules — see WalkResult.excluded). Surfaced so a consumer can report how
28
28
  // much of the tree was filtered out of the index.
29
29
  excluded: number;
30
+ // Change-tracking flags. DERIVED ONLY — they never influence records or
31
+ // ordering, so artifacts stay byte-identical whether or not anyone reads them.
32
+ //
33
+ // True iff a cache was supplied AND every kept file reused its cached record
34
+ // (via the stat fastpath or an exact content-hash match) AND the kept file
35
+ // set equals the cache's key set — i.e. this scan proved the indexed content
36
+ // identical to the previous build's. Docs never take the fastpath (see the
37
+ // docs exemption below), so their contribution is always an exact hash check.
38
+ contentUnchanged: boolean;
39
+ // True iff persisting this scan's cache would change at least one byte of the
40
+ // previous cache: some kept file's (hash, size, mtimeMs) differs from its
41
+ // cache entry, the kept file set differs from the cache's key set, or no
42
+ // cache was supplied at all. False means a cache rewrite would be pure churn.
43
+ cacheDirty: boolean;
30
44
  }
31
45
 
32
46
  export interface ScanOptions {
@@ -53,6 +67,12 @@ export interface ScanOptions {
53
67
  cache?: Map<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }>;
54
68
  // Disable the stat fastpath: read and re-hash every file (see BuildOptions).
55
69
  fullHash?: boolean;
70
+ // A walk result to use INSTEAD of walking here. MUST come from
71
+ // walk(root, <the same options as this scan>) — a walk of a different root
72
+ // or with different walk options would silently desynchronize the records
73
+ // from the tree. Exists for callers that already walked (e.g. a freshness
74
+ // probe) so scanRepo does not pay a second full directory traversal.
75
+ precomputedWalk?: WalkResult;
56
76
  }
57
77
 
58
78
  function countLines(s: string): number {
@@ -68,12 +88,14 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
68
88
  const scoped = opts.scope ? [...(opts.include ?? []), `${opts.scope.replace(/\/+$/, "")}/**`] : opts.include;
69
89
  const include = compileGlobs(scoped);
70
90
  const exclude = compileGlobs(opts.exclude);
71
- const { files: walked, capped, excluded } = walk(root, {
72
- maxFileBytes: opts.maxBytes,
73
- maxFiles: opts.maxFiles,
74
- gitignore: opts.gitignore,
75
- ignoreDirs: opts.ignoreDirs,
76
- });
91
+ const { files: walked, capped, excluded } =
92
+ opts.precomputedWalk ??
93
+ walk(root, {
94
+ maxFileBytes: opts.maxBytes,
95
+ maxFiles: opts.maxFiles,
96
+ gitignore: opts.gitignore,
97
+ ignoreDirs: opts.ignoreDirs,
98
+ });
77
99
  // Never index our own output (e.g. a committed `docs/ultraindex/`), or builds
78
100
  // would describe the encyclopedia instead of the code.
79
101
  const outPrefix = opts.out ? opts.out.replace(/\/+$/, "") + "/" : null;
@@ -83,6 +105,15 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
83
105
  const docText = new Map<string, string>();
84
106
  const mtimes = new Map<string, number>();
85
107
 
108
+ // Change-tracking accumulators (see RepoScan.contentUnchanged / cacheDirty).
109
+ // Derived only — nothing below feeds them back into records or ordering.
110
+ const cache = opts.cache;
111
+ // Every kept file so far reused its cached record (fastpath or hash match).
112
+ // Starts false with no cache: nothing can be proven unchanged against nothing.
113
+ let allReused = cache !== undefined;
114
+ // With no cache, persisting one is all new bytes — dirty by definition.
115
+ let cacheDirty = cache === undefined;
116
+
86
117
  for (const f of walked) {
87
118
  if (outPrefix && (f.abs === opts.out || f.abs.startsWith(outPrefix))) continue;
88
119
  if (include && !include(f.rel)) continue;
@@ -125,9 +156,17 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
125
156
  if (cached && cached.hash === hash) {
126
157
  files.push(cached.record);
127
158
  if (kind === "doc" && content) docText.set(f.rel, content);
159
+ // Content proven identical, but a (size, mtimeMs) drift — e.g. a bare
160
+ // touch, or an old cache without stat keys — still rewrites cache bytes.
161
+ if (cached.size !== f.size || cached.mtimeMs !== f.mtimeMs) cacheDirty = true;
128
162
  continue;
129
163
  }
130
164
 
165
+ // Past both reuse paths: this file is new to the cache or its content
166
+ // changed — the scan is not proven unchanged and the cache must be rewritten.
167
+ allReused = false;
168
+ cacheDirty = true;
169
+
131
170
  const record: FileRecord = {
132
171
  rel: f.rel,
133
172
  ext: f.ext,
@@ -176,5 +215,24 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
176
215
  }
177
216
 
178
217
  files.sort(byKey((f) => f.rel));
179
- return { root, commit: headCommit(root), files, languages, docText, mtimes, capped, excluded };
218
+ // File-set equality: reuse requires a cache entry and rels are unique, so
219
+ // with allReused the kept set is a subset of the cache keys — equality is
220
+ // then exactly a count match. A count mismatch (file added, deleted, or
221
+ // newly filtered) also means the persisted key set changes: dirty.
222
+ if (cache !== undefined && files.length !== cache.size) {
223
+ allReused = false;
224
+ cacheDirty = true;
225
+ }
226
+ return {
227
+ root,
228
+ commit: headCommit(root),
229
+ files,
230
+ languages,
231
+ docText,
232
+ mtimes,
233
+ capped,
234
+ excluded,
235
+ contentUnchanged: allReused,
236
+ cacheDirty,
237
+ };
180
238
  }
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single source of truth for the engine version the bundle reports. Kept in
2
2
  // lockstep with package.json by the release pipeline. Do not edit by hand
3
3
  // outside a release.
4
- export const ENGINE_VERSION = "2.13.0";
4
+ export const ENGINE_VERSION = "2.15.0";
5
5
 
6
6
  // Bumped whenever the on-disk artifact shape changes, so a consumer can reject
7
7
  // an index written by an incompatible engine instead of misreading it. The