@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/README.md +30 -10
- package/docs/MIGRATION.md +170 -36
- package/package.json +1 -1
- package/scripts/engine.d.mts +39 -4
- package/scripts/engine.mjs +1229 -721
- package/src/ast/grammars-pull.ts +178 -0
- package/src/ast/loader.ts +77 -15
- package/src/bm25.ts +16 -6
- package/src/callers.ts +10 -14
- package/src/complexity.ts +7 -1
- package/src/deadcode.ts +5 -4
- package/src/derived.ts +128 -0
- package/src/engine-cli.ts +288 -46
- package/src/engine.ts +20 -3
- package/src/hash.ts +5 -2
- package/src/mcp.ts +323 -24
- package/src/pipeline.ts +16 -5
- package/src/query.ts +8 -5
- package/src/scan.ts +66 -8
- package/src/types.ts +1 -1
- package/src/walk.ts +26 -9
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,
|
|
13
|
-
import {
|
|
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 =
|
|
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(
|
|
687
|
+
return renderGraphJson(getArtifacts(repo, scanOpts).graph);
|
|
411
688
|
}
|
|
412
689
|
if (name === "symbols") {
|
|
413
|
-
const { symbols } =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
741
|
+
const scan = getScan(repo, scanOpts);
|
|
465
742
|
const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
|
|
466
|
-
|
|
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(
|
|
775
|
+
return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
|
|
491
776
|
}
|
|
492
777
|
if (name === "complexity") {
|
|
493
|
-
const scan =
|
|
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 } =
|
|
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 } =
|
|
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 =
|
|
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 =
|
|
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 } =
|
|
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
|
|
605
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
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,
|
|
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 {
|
|
12
|
-
import {
|
|
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 =
|
|
98
|
+
const index = callerIndexFor(scan);
|
|
99
99
|
const entry = index.get(name);
|
|
100
|
-
|
|
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 =
|
|
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 } =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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.
|
|
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
|