@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/README.md +35 -11
- package/docs/MIGRATION.md +155 -36
- package/docs/SEMANTIC.md +12 -5
- package/package.json +1 -1
- package/scripts/engine.d.mts +49 -7
- package/scripts/engine.mjs +1233 -749
- package/src/ast/extract.ts +7 -5
- 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 +0 -0
- package/src/complexity.ts +7 -1
- package/src/deadcode.ts +5 -4
- package/src/derived.ts +128 -0
- package/src/embed/model.ts +23 -14
- package/src/engine-cli.ts +307 -51
- package/src/engine.ts +21 -4
- package/src/extract/code.ts +9 -5
- package/src/hash.ts +5 -2
- package/src/lang/js-ts.ts +1 -1
- package/src/mcp.ts +220 -26
- package/src/pipeline.ts +16 -5
- package/src/query.ts +8 -5
- package/src/scan.ts +74 -8
- package/src/types.ts +6 -3
- package/src/walk.ts +41 -11
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,
|
|
11
|
-
import {
|
|
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 =
|
|
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(
|
|
555
|
+
return renderGraphJson(getArtifacts(repo, scanOpts).graph);
|
|
384
556
|
}
|
|
385
557
|
if (name === "symbols") {
|
|
386
|
-
const { symbols } =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
609
|
+
const scan = getScan(repo, scanOpts);
|
|
438
610
|
const fn = name === "replace_symbol_body" ? replaceSymbolBody : name === "insert_after_symbol" ? insertAfterSymbol : insertBeforeSymbol;
|
|
439
|
-
|
|
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(
|
|
643
|
+
return JSON.stringify(findDeadCode(getScan(repo, scanOpts)), null, 2);
|
|
464
644
|
}
|
|
465
645
|
if (name === "complexity") {
|
|
466
|
-
const scan =
|
|
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 } =
|
|
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 } =
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
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 ?
|
|
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 } =
|
|
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
|
|
578
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
@@ -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 } =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|