@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/mcp.ts CHANGED
@@ -5,12 +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";
9
+ import { join } from "node:path";
8
10
  import { createInterface } from "node:readline";
9
- import { ENGINE_VERSION } from "./types.js";
10
- import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
11
- import { buildIndexArtifacts } from "./pipeline.js";
11
+ import { ENGINE_VERSION, type FileRecord } from "./types.js";
12
+ import { ensureGrammars, grammarKeysForExts } from "./ast/loader.js";
13
+ import { buildArtifactsFromScan, type IndexArtifacts } from "./pipeline.js";
12
14
  import { renderGraphJson } from "./render/graph-json.js";
13
- import { scanRepo, type RepoScan } from "./scan.js";
15
+ import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
16
+ import { walk } from "./walk.js";
14
17
  import { buildCallerIndex } from "./callers.js";
15
18
  import { detectWorkspaces } from "./workspaces.js";
16
19
  import { gitChurn } from "./git.js";
@@ -25,7 +28,7 @@ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit
25
28
  import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
26
29
  import { searchIndex } from "./bm25.js";
27
30
  import { checkRules, parseRules } from "./rules.js";
28
- import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
31
+ import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "./embed/model.js";
29
32
  import { buildEmbeddingIndex, type EmbeddingIndex } from "./embed/index.js";
30
33
  import { searchSemantic } from "./embed/search.js";
31
34
  import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
@@ -366,13 +369,182 @@ export async function memoizedEmbeddingIndex(
366
369
  return index;
367
370
  }
368
371
 
372
+ // A SINGLE entry — never an unbounded map — holding the most recent parse.
373
+ let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
374
+
375
+ // model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
376
+ // EVERY request dominates static-tier latency, so the parsed model is memoized
377
+ // across requests. One statSync per request keys the cache on
378
+ // (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
379
+ // Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
380
+ // the throw propagates and the cache is left as it was, so the next request
381
+ // retries from scratch. A missing model.json returns undefined (the
382
+ // not-present case, exactly like loadEmbedModel).
383
+ export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
384
+ let stat;
385
+ try {
386
+ stat = statSync(join(modelDir, "model.json"));
387
+ } catch {
388
+ return undefined;
389
+ }
390
+ const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
391
+ if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
392
+ const model = loadEmbedModel(modelDir);
393
+ if (model) embedModelCache = { key, model };
394
+ return model;
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 SessionCacheMap = Map<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }>;
423
+
424
+ // A SINGLE entry — never an unbounded map — holding the most recent scan.
425
+ let sessionCache:
426
+ | { key: string; scan: RepoScan; cacheMap: SessionCacheMap; arts?: IndexArtifacts }
427
+ | undefined;
428
+
429
+ // Fixed property order (and JSON.stringify dropping undefined) keeps the key
430
+ // deterministic regardless of how the caller assembled the options object.
431
+ function sessionKey(repo: string, opts: SessionScanOptions): string {
432
+ return (
433
+ repo +
434
+ "\0" +
435
+ JSON.stringify({
436
+ scope: opts.scope,
437
+ include: opts.include,
438
+ exclude: opts.exclude,
439
+ gitignore: opts.gitignore,
440
+ ignoreDirs: opts.ignoreDirs,
441
+ maxBytes: opts.maxBytes,
442
+ maxFiles: opts.maxFiles,
443
+ maxCallsPerFile: opts.maxCallsPerFile,
444
+ out: opts.out,
445
+ fullHash: opts.fullHash,
446
+ })
447
+ );
448
+ }
449
+
450
+ // A scan re-expressed as the `ScanOptions.cache` shape (the exact map the CLI
451
+ // persists as cache.json): rel → (hash, record, size, mtimeMs), so the next
452
+ // scanRepo can take the stat fastpath / hash-match reuse paths against it.
453
+ // Exported for tests.
454
+ export function toCacheMap(scan: RepoScan): SessionCacheMap {
455
+ const m: SessionCacheMap = new Map();
456
+ for (const f of scan.files) m.set(f.rel, { hash: f.hash, record: f, size: f.size, mtimeMs: scan.mtimes.get(f.rel) });
457
+ return m;
458
+ }
459
+
460
+ // The memoizing replacement for scanRepo inside callTool. Exported for tests.
461
+ export function getScan(repo: string, opts: SessionScanOptions = {}): RepoScan {
462
+ const key = sessionKey(repo, opts);
463
+ if (sessionCache && sessionCache.key === key) {
464
+ const fresh = scanRepo(repo, { ...opts, cache: sessionCache.cacheMap });
465
+ if (fresh.contentUnchanged) {
466
+ // Content proven identical → return the SAME object (object identity is
467
+ // what keeps derived.ts's WeakMap and the memoized artifacts warm). A
468
+ // stat-only drift (e.g. a bare touch) still refreshes the cache map so
469
+ // the next call's stat fastpath keys on the new (size, mtimeMs).
470
+ if (fresh.cacheDirty) sessionCache.cacheMap = toCacheMap(fresh);
471
+ // `commit` (headCommit(root)) is NOT part of the stat/hash freshness
472
+ // oracle: a git HEAD move that leaves the worktree untouched — commit /
473
+ // commit --amend / reset --soft / checkout to an identical-tree branch —
474
+ // changes headCommit without altering any file's size or mtime, so
475
+ // contentUnchanged stays true while the cached scan's commit went stale.
476
+ // `fresh` recomputed it just now (exactly what a cold process reports), so
477
+ // sync it onto the returned object; otherwise scan_summary would emit the
478
+ // OLD commit a from-scratch scanRepo never would. Mutate the SAME object
479
+ // rather than clone — cloning would forfeit the identity the artifacts and
480
+ // derived.ts WeakMap key on. Safe: no artifact carries commit (graph /
481
+ // symbols render byte-identically regardless), so nothing memoized here
482
+ // depends on this field.
483
+ if (sessionCache.scan.commit !== fresh.commit) sessionCache.scan.commit = fresh.commit;
484
+ return sessionCache.scan;
485
+ }
486
+ sessionCache = { key, scan: fresh, cacheMap: toCacheMap(fresh) };
487
+ return fresh;
488
+ }
489
+ const scan = scanRepo(repo, opts);
490
+ sessionCache = { key, scan, cacheMap: toCacheMap(scan) };
491
+ return scan;
492
+ }
493
+
494
+ // Lazy pipeline memoized on scan OBJECT IDENTITY: graph-shaped tools reuse the
495
+ // artifacts exactly as long as getScan keeps returning the same scan object.
496
+ // Exported for tests.
497
+ export function getArtifacts(repo: string, opts: SessionScanOptions = {}): IndexArtifacts {
498
+ const scan = getScan(repo, opts);
499
+ if (sessionCache && sessionCache.scan === scan) {
500
+ return (sessionCache.arts ??= buildArtifactsFromScan(scan, opts));
501
+ }
502
+ // Defensive fallback (getScan always leaves sessionCache holding `scan`).
503
+ return buildArtifactsFromScan(scan, opts);
504
+ }
505
+
506
+ // Warm the grammars for the languages CURRENTLY present in `repo`, re-derived on
507
+ // EVERY scan-needing call — never frozen on first touch. The server no longer
508
+ // warms every committed grammar at startup; most sessions touch one repo and a
509
+ // handful of languages, so each scan-needing tool warms the walk-derived set
510
+ // itself. It MUST re-derive per call because the session cache (getScan) is built
511
+ // to pick up mid-session file adds/edits/removes: a language whose first file
512
+ // appears only AFTER the initial scan-needing call must still get its grammar
513
+ // warmed, or that file falls to the regex tier and its symbols diverge from a
514
+ // cold build on the identical on-disk state — a byte-identity break. (A per-
515
+ // repo-path memo froze the grammar set at first touch and silently missed
516
+ // exactly this case.) ensureGrammars is idempotent and near-free once a grammar
517
+ // is loaded — it warms only newly-seen keys — so the sole repeated cost is the
518
+ // walk; the wasm for a given language loads at most once. Determinism: the walk's
519
+ // extension set is a superset of what scanRepo keeps (scope/include/exclude only
520
+ // filter further), so every extracted file has its grammar loaded; Language.load
521
+ // calls are independent, so warming fewer grammars cannot alter the parse of a
522
+ // loaded one.
523
+ export async function warmGrammarsForRepo(repo: string): Promise<void> {
524
+ const { files } = walk(repo, {});
525
+ await ensureGrammars(grammarKeysForExts(files.map((f) => f.ext)));
526
+ }
527
+
528
+ // Tools that never scan the file tree (git/grep/memory/embed-status only) — they
529
+ // must not trigger a grammar warm. Every other tool is scan-needing and warms
530
+ // the repo's grammars first; defaulting to "warm" keeps a newly added scan tool
531
+ // correct without having to be listed here.
532
+ const SCANLESS_TOOLS = new Set([
533
+ "workspaces", "churn", "coupling", "grep",
534
+ "write_memory", "read_memory", "list_memories", "delete_memory",
535
+ "embed_status",
536
+ ]);
537
+
369
538
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
370
539
  const repo = str(args.repo);
371
540
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
372
541
  const scanOpts = { scope: str(args.scope), include: strArray(args.include), exclude: strArray(args.exclude) };
542
+ // Scan-needing tools warm the present-language grammars (re-derived per call)
543
+ // before any scan so extraction takes the AST tier; scan-less tools skip it.
544
+ if (!SCANLESS_TOOLS.has(name)) await warmGrammarsForRepo(repo);
373
545
 
374
546
  if (name === "scan_summary") {
375
- const scan = scanRepo(repo, scanOpts);
547
+ const scan = getScan(repo, scanOpts);
376
548
  return JSON.stringify(
377
549
  { engineVersion: ENGINE_VERSION, commit: scan.commit, fileCount: scan.files.length, languages: scan.languages, capped: scan.capped },
378
550
  null,
@@ -380,10 +552,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
380
552
  );
381
553
  }
382
554
  if (name === "graph") {
383
- return renderGraphJson(buildIndexArtifacts(repo, scanOpts).graph);
555
+ return renderGraphJson(getArtifacts(repo, scanOpts).graph);
384
556
  }
385
557
  if (name === "symbols") {
386
- const { symbols } = buildIndexArtifacts(repo, scanOpts);
558
+ const { symbols } = getArtifacts(repo, scanOpts);
387
559
  const lookup = str(args.name);
388
560
  if (lookup) {
389
561
  return JSON.stringify({ name: lookup, defs: symbols.defs[lookup] ?? [], refs: symbols.refs[lookup] ?? [] }, null, 2);
@@ -391,7 +563,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
391
563
  return JSON.stringify(symbols, null, 2);
392
564
  }
393
565
  if (name === "callers") {
394
- const index = buildCallerIndex(scanRepo(repo, scanOpts));
566
+ const index = buildCallerIndex(getScan(repo, scanOpts));
395
567
  const lookup = str(args.name);
396
568
  if (lookup) {
397
569
  const entry = index.get(lookup);
@@ -414,12 +586,12 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
414
586
  if (name === "symbols_overview") {
415
587
  const file = str(args.file);
416
588
  if (!file) throw new Error("`file` is required");
417
- return JSON.stringify(symbolsOverview(scanRepo(repo, scanOpts), file), null, 2);
589
+ return JSON.stringify(symbolsOverview(getScan(repo, scanOpts), file), null, 2);
418
590
  }
419
591
  if (name === "find_symbol") {
420
592
  const namePath = str(args.namePath);
421
593
  if (!namePath) throw new Error("`namePath` is required");
422
- const matches = findSymbol(scanRepo(repo, scanOpts), namePath, {
594
+ const matches = findSymbol(getScan(repo, scanOpts), namePath, {
423
595
  substring: args.substring === true,
424
596
  includeBody: args.includeBody === true,
425
597
  });
@@ -428,15 +600,23 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
428
600
  if (name === "find_references") {
429
601
  const symName = str(args.name);
430
602
  if (!symName) throw new Error("`name` is required");
431
- return JSON.stringify(findReferences(scanRepo(repo, scanOpts), symName), null, 2);
603
+ return JSON.stringify(findReferences(getScan(repo, scanOpts), symName), null, 2);
432
604
  }
433
605
  if (name === "replace_symbol_body" || name === "insert_after_symbol" || name === "insert_before_symbol") {
434
606
  const namePath = str(args.namePath);
435
607
  const body = typeof args.body === "string" ? args.body : undefined;
436
608
  if (!namePath || body === undefined) throw new Error("`namePath` and `body` are required");
437
- const scan = scanRepo(repo, scanOpts);
609
+ const scan = getScan(repo, scanOpts);
438
610
  const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
439
- return JSON.stringify(fn(scan, namePath, body, str(args.file)), null, 2);
611
+ const result = fn(scan, namePath, body, str(args.file));
612
+ // A write WE just performed must not be trusted to the stat oracle: an
613
+ // edit landing in the same mtime tick with the same byte count would pass
614
+ // the (size, mtimeMs) fastpath and serve a stale scan. Drop the whole
615
+ // session entry unconditionally — the next call rescans from scratch.
616
+ // (write_memory needs no invalidation: .codeindex/ is excluded from the
617
+ // walk, so memories never enter a scan.)
618
+ sessionCache = undefined;
619
+ return JSON.stringify(result, null, 2);
440
620
  }
441
621
  if (name === "write_memory") {
442
622
  const memName = str(args.name);
@@ -460,10 +640,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
460
640
  return JSON.stringify({ deleted: deleteMemory(repo, memName) }, null, 2);
461
641
  }
462
642
  if (name === "dead_code") {
463
- return JSON.stringify(findDeadCode(scanRepo(repo, scanOpts)), null, 2);
643
+ return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
464
644
  }
465
645
  if (name === "complexity") {
466
- const scan = scanRepo(repo, scanOpts);
646
+ const scan = getScan(repo, scanOpts);
467
647
  if (args.risk === true) {
468
648
  const { churn, ok } = gitChurn(repo);
469
649
  return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2);
@@ -471,15 +651,15 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
471
651
  return JSON.stringify(symbolComplexity(scan, str(args.file)), null, 2);
472
652
  }
473
653
  if (name === "mermaid") {
474
- const { graph } = buildIndexArtifacts(repo, scanOpts);
654
+ const { graph } = getArtifacts(repo, scanOpts);
475
655
  return renderMermaid(graph, { module: str(args.module) });
476
656
  }
477
657
  if (name === "repo_map") {
478
- const { scan, graph } = buildIndexArtifacts(repo, scanOpts);
658
+ const { scan, graph } = getArtifacts(repo, scanOpts);
479
659
  return renderRepoMap(scan, graph, { budgetTokens: typeof args.budgetTokens === "number" ? args.budgetTokens : undefined });
480
660
  }
481
661
  if (name === "hotspots") {
482
- const scan = scanRepo(repo, scanOpts);
662
+ const scan = getScan(repo, scanOpts);
483
663
  const { churn, ok } = gitChurn(repo, { since: str(args.since) });
484
664
  return JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2);
485
665
  }
@@ -500,7 +680,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
500
680
  if (name === "search") {
501
681
  const query = str(args.query);
502
682
  if (!query) throw new Error("`query` is required");
503
- const scan = scanRepo(repo, scanOpts);
683
+ const scan = getScan(repo, scanOpts);
504
684
  const limit = typeof args.limit === "number" ? args.limit : undefined;
505
685
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
506
686
  if (args.semantic === true) {
@@ -530,7 +710,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
530
710
  }
531
711
  }
532
712
  const modelDir = resolveEmbedModelDir(repo);
533
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
713
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
534
714
  if (model) {
535
715
  const index = await memoizedEmbeddingIndex(
536
716
  { mode: "static", identity: `${modelDir}#${model.modelId}`, scan },
@@ -552,7 +732,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
552
732
  }
553
733
  if (name === "embed_status") {
554
734
  const modelDir = resolveEmbedModelDir(repo);
555
- const model = modelDir ? loadEmbedModel(modelDir) : undefined;
735
+ const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
556
736
  const endpoint = resolveEmbedEndpoint();
557
737
  const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
558
738
  const status: Record<string, unknown> = {
@@ -568,14 +748,28 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
568
748
  }
569
749
  if (name === "check_rules") {
570
750
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
571
- const { graph } = buildIndexArtifacts(repo, scanOpts);
751
+ const { graph } = getArtifacts(repo, scanOpts);
572
752
  return JSON.stringify(checkRules(graph, rules), null, 2);
573
753
  }
574
754
  throw new Error(`unknown tool: ${name}`);
575
755
  }
576
756
 
577
- export async function runMcpServer(): Promise<void> {
578
- await ensureGrammars(allGrammarKeys()); // AST tier when the sidecar is present
757
+ export interface McpServerOptions {
758
+ // Override the serverInfo announced in the initialize response — for
759
+ // downstream consumers embedding this server under their own identity.
760
+ // Omitted fields keep the defaults (name "codeindex", ENGINE_VERSION).
761
+ serverInfo?: { name?: string; version?: string };
762
+ }
763
+
764
+ export async function runMcpServer(opts: McpServerOptions = {}): Promise<void> {
765
+ const serverInfo = {
766
+ name: opts.serverInfo?.name ?? "codeindex",
767
+ version: opts.serverInfo?.version ?? ENGINE_VERSION,
768
+ };
769
+ // No startup warm: each scan-needing tool warms the present-language grammars
770
+ // for its repo before it runs (warmGrammarsForRepo re-derives them per call),
771
+ // so a session that never scans — or only touches one language — loads no
772
+ // unused wasm, and a language first seen mid-session still gets warmed.
579
773
 
580
774
  const send = (msg: Record<string, unknown>): void => {
581
775
  process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
@@ -608,7 +802,7 @@ export async function runMcpServer(): Promise<void> {
608
802
  result: {
609
803
  protocolVersion: "2024-11-05",
610
804
  capabilities: { tools: {} },
611
- serverInfo: { name: "codeindex", version: ENGINE_VERSION },
805
+ serverInfo,
612
806
  },
613
807
  });
614
808
  } 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 {
@@ -36,8 +50,15 @@ export interface ScanOptions {
36
50
  scope?: string;
37
51
  // Honor .gitignore files (default true — see WalkOptions.gitignore).
38
52
  gitignore?: boolean;
53
+ // Directory names to skip — REPLACES the default set (see
54
+ // WalkOptions.ignoreDirs; compose with the IGNORE_DIRS export to extend it).
55
+ ignoreDirs?: string[];
39
56
  maxBytes?: number;
40
57
  maxFiles?: number;
58
+ // Per-file call-site cap for extraction (default 512, both AST and regex
59
+ // tiers). Raising it trades index size for call-graph recall; dedup/sort
60
+ // semantics are unchanged. Absent, output is byte-identical to before.
61
+ maxCallsPerFile?: number;
41
62
  out?: string; // absolute output dir to exclude from the scan (self-index guard)
42
63
  // Previous build's extraction cache (rel → {hash, record, size?, mtimeMs?}). A
43
64
  // file whose (size,mtime) key matches skips read+hash entirely (the stat
@@ -46,6 +67,12 @@ export interface ScanOptions {
46
67
  cache?: Map<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }>;
47
68
  // Disable the stat fastpath: read and re-hash every file (see BuildOptions).
48
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;
49
76
  }
50
77
 
51
78
  function countLines(s: string): number {
@@ -61,11 +88,14 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
61
88
  const scoped = opts.scope ? [...(opts.include ?? []), `${opts.scope.replace(/\/+$/, "")}/**`] : opts.include;
62
89
  const include = compileGlobs(scoped);
63
90
  const exclude = compileGlobs(opts.exclude);
64
- const { files: walked, capped, excluded } = walk(root, {
65
- maxFileBytes: opts.maxBytes,
66
- maxFiles: opts.maxFiles,
67
- gitignore: opts.gitignore,
68
- });
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
+ });
69
99
  // Never index our own output (e.g. a committed `docs/ultraindex/`), or builds
70
100
  // would describe the encyclopedia instead of the code.
71
101
  const outPrefix = opts.out ? opts.out.replace(/\/+$/, "") + "/" : null;
@@ -75,6 +105,15 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
75
105
  const docText = new Map<string, string>();
76
106
  const mtimes = new Map<string, number>();
77
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
+
78
117
  for (const f of walked) {
79
118
  if (outPrefix && (f.abs === opts.out || f.abs.startsWith(outPrefix))) continue;
80
119
  if (include && !include(f.rel)) continue;
@@ -117,9 +156,17 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
117
156
  if (cached && cached.hash === hash) {
118
157
  files.push(cached.record);
119
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;
120
162
  continue;
121
163
  }
122
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
+
123
170
  const record: FileRecord = {
124
171
  rel: f.rel,
125
172
  ext: f.ext,
@@ -144,7 +191,7 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
144
191
  // Non-markdown prose (.rst/.txt): title from basename, no link graph.
145
192
  record.title = basename(f.rel);
146
193
  } else if (kind === "code") {
147
- const code = extractCode(f.rel, f.ext, content);
194
+ const code = extractCode(f.rel, f.ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
148
195
  record.title = basename(f.rel);
149
196
  record.summary = code.summary;
150
197
  record.symbols = code.symbols;
@@ -168,5 +215,24 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
168
215
  }
169
216
 
170
217
  files.sort(byKey((f) => f.rel));
171
- 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
+ };
172
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.12.0";
4
+ export const ENGINE_VERSION = "2.14.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
@@ -37,8 +37,11 @@ export const SCHEMA_VERSION = 4;
37
37
  // resolved export-alias symbol also cite the original declaration's own line
38
38
  // (and endLine, when the AST tier populated one) instead of the export
39
39
  // statement's line — a citation-precision fix (issue #9) for consumers
40
- // (ultradoc) that use file:line as evidence.
41
- export const EXTRACTOR_VERSION = 9;
40
+ // (ultradoc) that use file:line as evidence; v10 stops the regex tier
41
+ // emitting a spurious exported class symbol named "extends" for
42
+ // `export default class extends Base {}` (issue #11) — the file-stem
43
+ // default symbol is unchanged.
44
+ export const EXTRACTOR_VERSION = 10;
42
45
 
43
46
  // How a file is classified. `code` gets symbol/import extraction; `doc` gets
44
47
  // link/heading extraction; the rest are catalogued but not deeply parsed.