@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/mcp.ts CHANGED
@@ -8,11 +8,12 @@
8
8
  import { 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, type FileRecord } 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,157 @@ 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 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
+
396
538
  async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
397
539
  const repo = str(args.repo);
398
540
  if (!repo) throw new Error("`repo` is required (absolute path to the repository root)");
399
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);
400
545
 
401
546
  if (name === "scan_summary") {
402
- const scan = scanRepo(repo, scanOpts);
547
+ const scan = getScan(repo, scanOpts);
403
548
  return JSON.stringify(
404
549
  { engineVersion: ENGINE_VERSION, commit: scan.commit, fileCount: scan.files.length, languages: scan.languages, capped: scan.capped },
405
550
  null,
@@ -407,10 +552,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
407
552
  );
408
553
  }
409
554
  if (name === "graph") {
410
- return renderGraphJson(buildIndexArtifacts(repo, scanOpts).graph);
555
+ return renderGraphJson(getArtifacts(repo, scanOpts).graph);
411
556
  }
412
557
  if (name === "symbols") {
413
- const { symbols } = buildIndexArtifacts(repo, scanOpts);
558
+ const { symbols } = getArtifacts(repo, scanOpts);
414
559
  const lookup = str(args.name);
415
560
  if (lookup) {
416
561
  return JSON.stringify({ name: lookup, defs: symbols.defs[lookup] ?? [], refs: symbols.refs[lookup] ?? [] }, null, 2);
@@ -418,7 +563,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
418
563
  return JSON.stringify(symbols, null, 2);
419
564
  }
420
565
  if (name === "callers") {
421
- const index = buildCallerIndex(scanRepo(repo, scanOpts));
566
+ const index = buildCallerIndex(getScan(repo, scanOpts));
422
567
  const lookup = str(args.name);
423
568
  if (lookup) {
424
569
  const entry = index.get(lookup);
@@ -441,12 +586,12 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
441
586
  if (name === "symbols_overview") {
442
587
  const file = str(args.file);
443
588
  if (!file) throw new Error("`file` is required");
444
- return JSON.stringify(symbolsOverview(scanRepo(repo, scanOpts), file), null, 2);
589
+ return JSON.stringify(symbolsOverview(getScan(repo, scanOpts), file), null, 2);
445
590
  }
446
591
  if (name === "find_symbol") {
447
592
  const namePath = str(args.namePath);
448
593
  if (!namePath) throw new Error("`namePath` is required");
449
- const matches = findSymbol(scanRepo(repo, scanOpts), namePath, {
594
+ const matches = findSymbol(getScan(repo, scanOpts), namePath, {
450
595
  substring: args.substring === true,
451
596
  includeBody: args.includeBody === true,
452
597
  });
@@ -455,15 +600,23 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
455
600
  if (name === "find_references") {
456
601
  const symName = str(args.name);
457
602
  if (!symName) throw new Error("`name` is required");
458
- return JSON.stringify(findReferences(scanRepo(repo, scanOpts), symName), null, 2);
603
+ return JSON.stringify(findReferences(getScan(repo, scanOpts), symName), null, 2);
459
604
  }
460
605
  if (name === "replace_symbol_body" || name === "insert_after_symbol" || name === "insert_before_symbol") {
461
606
  const namePath = str(args.namePath);
462
607
  const body = typeof args.body === "string" ? args.body : undefined;
463
608
  if (!namePath || body === undefined) throw new Error("`namePath` and `body` are required");
464
- const scan = scanRepo(repo, scanOpts);
609
+ const scan = getScan(repo, scanOpts);
465
610
  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);
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);
467
620
  }
468
621
  if (name === "write_memory") {
469
622
  const memName = str(args.name);
@@ -487,10 +640,10 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
487
640
  return JSON.stringify({ deleted: deleteMemory(repo, memName) }, null, 2);
488
641
  }
489
642
  if (name === "dead_code") {
490
- return JSON.stringify(findDeadCode(scanRepo(repo, scanOpts)), null, 2);
643
+ return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
491
644
  }
492
645
  if (name === "complexity") {
493
- const scan = scanRepo(repo, scanOpts);
646
+ const scan = getScan(repo, scanOpts);
494
647
  if (args.risk === true) {
495
648
  const { churn, ok } = gitChurn(repo);
496
649
  return JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2);
@@ -498,15 +651,15 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
498
651
  return JSON.stringify(symbolComplexity(scan, str(args.file)), null, 2);
499
652
  }
500
653
  if (name === "mermaid") {
501
- const { graph } = buildIndexArtifacts(repo, scanOpts);
654
+ const { graph } = getArtifacts(repo, scanOpts);
502
655
  return renderMermaid(graph, { module: str(args.module) });
503
656
  }
504
657
  if (name === "repo_map") {
505
- const { scan, graph } = buildIndexArtifacts(repo, scanOpts);
658
+ const { scan, graph } = getArtifacts(repo, scanOpts);
506
659
  return renderRepoMap(scan, graph, { budgetTokens: typeof args.budgetTokens === "number" ? args.budgetTokens : undefined });
507
660
  }
508
661
  if (name === "hotspots") {
509
- const scan = scanRepo(repo, scanOpts);
662
+ const scan = getScan(repo, scanOpts);
510
663
  const { churn, ok } = gitChurn(repo, { since: str(args.since) });
511
664
  return JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2);
512
665
  }
@@ -527,7 +680,7 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
527
680
  if (name === "search") {
528
681
  const query = str(args.query);
529
682
  if (!query) throw new Error("`query` is required");
530
- const scan = scanRepo(repo, scanOpts);
683
+ const scan = getScan(repo, scanOpts);
531
684
  const limit = typeof args.limit === "number" ? args.limit : undefined;
532
685
  const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
533
686
  if (args.semantic === true) {
@@ -595,14 +748,28 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
595
748
  }
596
749
  if (name === "check_rules") {
597
750
  const rules = parseRules(args.rules); // throws a descriptive error on a malformed payload
598
- const { graph } = buildIndexArtifacts(repo, scanOpts);
751
+ const { graph } = getArtifacts(repo, scanOpts);
599
752
  return JSON.stringify(checkRules(graph, rules), null, 2);
600
753
  }
601
754
  throw new Error(`unknown tool: ${name}`);
602
755
  }
603
756
 
604
- export async function runMcpServer(): Promise<void> {
605
- 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.
606
773
 
607
774
  const send = (msg: Record<string, unknown>): void => {
608
775
  process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
@@ -635,7 +802,7 @@ export async function runMcpServer(): Promise<void> {
635
802
  result: {
636
803
  protocolVersion: "2024-11-05",
637
804
  capabilities: { tools: {} },
638
- serverInfo: { name: "codeindex", version: ENGINE_VERSION },
805
+ serverInfo,
639
806
  },
640
807
  });
641
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 {
@@ -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.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
package/src/walk.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readdirSync, statSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { readdirSync, statSync, lstatSync, readFileSync, realpathSync, type Dirent } from "node:fs";
2
2
  import { join, relative, sep, extname } from "node:path";
3
3
  import { parseGitignore, isIgnored, type IgnoreRule } from "./ignore.js";
4
4
 
@@ -113,27 +113,44 @@ export function walk(root: string, opts: WalkOptions = {}): WalkResult {
113
113
  if (seenDirs.has(real)) continue;
114
114
  seenDirs.add(real);
115
115
  if (!contained(real)) continue; // dir symlink escaping the repo
116
- let entries: string[];
116
+ let entries: Dirent[];
117
117
  try {
118
118
  // Sorted so the walk order — and therefore WHICH files survive a
119
- // maxFiles cap — is identical across filesystems and machines.
120
- entries = readdirSync(frame.dir).sort();
119
+ // maxFiles cap — is identical across filesystems and machines. Dirents
120
+ // sort by .name under the same code-unit comparison the bare-string
121
+ // sort used, so the order is byte-identical to the previous readdir.
122
+ entries = readdirSync(frame.dir, { withFileTypes: true }).sort((a, b) =>
123
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
124
+ );
121
125
  } catch {
122
126
  continue;
123
127
  }
124
128
  let rules = frame.rules;
125
- if (useGitignore && entries.includes(".gitignore")) {
129
+ if (useGitignore && entries.some((e) => e.name === ".gitignore")) {
126
130
  const parsed = parseGitignore(readText(join(frame.dir, ".gitignore")), frame.rel);
127
131
  if (parsed.length) rules = [...rules, ...parsed];
128
132
  }
129
- for (const name of entries) {
133
+ for (const entry of entries) {
134
+ const name = entry.name;
130
135
  const abs = join(frame.dir, name);
131
136
  const rel = frame.rel ? `${frame.rel}/${name}` : name;
137
+ // The dirent type IS the lstat type (Node lstats internally when the
138
+ // filesystem can't supply it), so no lstat is needed to detect links.
139
+ const isLink = entry.isSymbolicLink();
140
+ // Ignored-directory boundary (node_modules, .git…): skip on the dirent
141
+ // type alone — ZERO stat syscalls. A symlink reports isDirectory()
142
+ // false on its dirent and falls through to the stat-based
143
+ // classification below, so a link named node_modules still classifies
144
+ // by its target exactly as before.
145
+ if (entry.isDirectory() && ignoreDirs.has(name)) continue;
132
146
  let st;
133
- let isLink: boolean;
134
147
  try {
135
- st = statSync(abs);
136
- isLink = lstatSync(abs).isSymbolicLink();
148
+ // Non-links: a single lstatSync supplies isDirectory/isFile/size/
149
+ // mtimeMs — field-for-field what the previous statSync returned,
150
+ // since with no link to follow the two calls are identical. Links:
151
+ // keep the following statSync so the entry classifies by its TARGET;
152
+ // a broken link throws here and is skipped, same as before.
153
+ st = isLink ? statSync(abs) : lstatSync(abs);
137
154
  } catch {
138
155
  continue;
139
156
  }