@maxgfr/codeindex 2.17.1 → 2.19.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/engine-cli.ts CHANGED
@@ -4,12 +4,14 @@ import { SCHEMA_VERSION, EXTRACTOR_VERSION, type FileRecord } from "./types.js";
4
4
  import { ENGINE_VERSION } from "./types.js";
5
5
  import { ensureGrammars, grammarKeysForExts, resolveGrammarsTier, sharedGrammarsCacheDir } from "./ast/loader.js";
6
6
  import { resolveGrammarsPullTarget, pullGrammars } from "./ast/grammars-pull.js";
7
- import { buildIndexArtifacts, buildArtifactsFromScan, type BuildIndexOptions } from "./pipeline.js";
7
+ import { buildIndexArtifacts, buildArtifactsFromScan, type BuildIndexOptions, type IndexArtifacts } from "./pipeline.js";
8
8
  import { sha1 } from "./hash.js";
9
9
  import { renderGraphJson } from "./render/graph-json.js";
10
10
  import { renderSymbolsJson } from "./render/symbols-json.js";
11
11
  import { renderScip } from "./render/scip.js";
12
- import { scanRepo } from "./scan.js";
12
+ import { scanRepo, scanSummary, type RepoScan } from "./scan.js";
13
+ import { scanRepoParallel } from "./pool.js";
14
+ import { preloadSession, INDEX_DIR } from "./preload.js";
13
15
  import { walk, type WalkResult } from "./walk.js";
14
16
  import { buildCallerIndex } from "./callers.js";
15
17
  import { detectWorkspaces } from "./workspaces.js";
@@ -20,6 +22,8 @@ import { renderRepoMap } from "./repomap.js";
20
22
  import { findDeadCode } from "./deadcode.js";
21
23
  import { symbolComplexity, riskHotspots } from "./complexity.js";
22
24
  import { renderMermaid } from "./viz.js";
25
+ import { impactOf, neighborsOf } from "./traverse.js";
26
+ import { deltaFor, formatDeltaPanel } from "./delta.js";
23
27
  import { searchIndex } from "./bm25.js";
24
28
  import { checkRules, parseRules } from "./rules.js";
25
29
  import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, parseEmbedModel, resolveEmbedPullUrl, fetchEmbedModel } from "./embed/model.js";
@@ -83,6 +87,13 @@ Commands:
83
87
  complexity Cyclomatic-complexity estimates, most-complex first. Pass a file
84
88
  positional for one file; omit for the repo-wide top
85
89
  risk Complexity × git-churn ranking (JSON; --since <ref> to bound)
90
+ delta Review panel for the git diff: changed files -> enclosing symbols ->
91
+ blast radius -> risk score with explained reasons
92
+ (--base <ref> | --staged, --depth <n>, --json)
93
+ impact Reverse dependency closure of a file or module: everything that
94
+ transitively imports/uses/calls it (--depth <n>; JSON)
95
+ neighbors Graph neighbours of a file or module, both directions
96
+ (--depth <n>, --kind import,call,use,doc-link,mention; JSON)
86
97
  mermaid Mermaid diagram of the module graph; pass a module positional to
87
98
  focus on one neighborhood
88
99
  rewrite Map an expensive tree-wide search onto its indexed equivalent:
@@ -97,7 +108,10 @@ Commands:
97
108
  check_rules, the memory quartet and the three symbolic-edit
98
109
  writes). Flags: --repo <dir> pins ONE repository so the per-tool
99
110
  repo argument becomes optional (an explicit per-call repo still
100
- wins); --server-name <name> overrides the announced serverInfo
111
+ wins); --server-name <name> overrides the announced serverInfo;
112
+ --max-response-bytes <n> caps a single tool response (default 1e6;
113
+ a response under the cap is byte-identical, one over it is
114
+ replaced by an actionable notice instead of an unusable blob)
101
115
  version Print the engine version
102
116
 
103
117
  Flags (accepted before OR after the subcommand: '--repo X scan' and
@@ -117,6 +131,16 @@ Flags (accepted before OR after the subcommand: '--repo X scan' and
117
131
  --max-bytes <n> Skip files above this size (default 1 MiB)
118
132
  --max-calls <n> Per-file call-site cap for extraction (default 512)
119
133
  --no-ast Skip tree-sitter grammars even when present (regex tier)
134
+ --workers <n> \`index\`: extraction worker threads (default: cores-1,
135
+ capped at 8; 0 or 1 forces the single-threaded path).
136
+ Also settable with CODEINDEX_WORKERS. Artifacts are
137
+ byte-identical either way
138
+ --index <dir> Persisted index the READ commands reuse, relative to the
139
+ repo (default .codeindex — i.e. what \`index --out\` wrote
140
+ there). A fresh index turns the scan into a stat pass and,
141
+ when it still matches the worktree, skips the pipeline
142
+ entirely. Stale/absent/corrupt → a normal cold build
143
+ --no-index-cache Never reuse a persisted index; always build from scratch
120
144
  --config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
121
145
  --limit <n> Max results for \`search\` (default 20)
122
146
  --no-fuzzy \`search\`: disable trigram fuzzy fallback for query terms
@@ -144,6 +168,9 @@ interface CliFlags {
144
168
  maxBytes?: number;
145
169
  maxCalls?: number;
146
170
  noAst: boolean;
171
+ workers?: number; // extraction worker threads (0/1 = sequential)
172
+ indexDir?: string; // persisted index to read (default .codeindex)
173
+ noIndexCache?: boolean; // never reuse a persisted index
147
174
  since?: string;
148
175
  ignoreCase?: boolean;
149
176
  maxHits?: number;
@@ -155,6 +182,11 @@ interface CliFlags {
155
182
  recall?: boolean; // callers: recall-oriented binding
156
183
  run?: boolean; // `embed serve`: actually run the docker command (default: print)
157
184
  projectRoot?: string; // scip: override Metadata.project_root
185
+ base?: string; // delta: branch/ref to diff against (default: the repo's default branch)
186
+ staged?: boolean; // delta: diff the index instead of the merge-base
187
+ depth?: number; // delta/impact/neighbors: traversal hops
188
+ kind?: string; // neighbors: comma-separated edge kinds to traverse
189
+ json?: boolean; // delta: emit JSON instead of the human panel
158
190
  positional?: string; // e.g. the grep pattern or search query
159
191
  }
160
192
 
@@ -190,6 +222,15 @@ function parseFlags(args: string[]): CliFlags {
190
222
  else if (a === "--max-hits") flags.maxHits = num();
191
223
  else if (a === "--budget-tokens") flags.budgetTokens = num();
192
224
  else if (a === "--no-ast") flags.noAst = true;
225
+ else if (a === "--index") flags.indexDir = next();
226
+ else if (a === "--no-index-cache") flags.noIndexCache = true;
227
+ else if (a === "--workers") {
228
+ // 0 is meaningful here (force sequential), so this cannot use num().
229
+ const raw = next();
230
+ const n = Number(raw);
231
+ if (!Number.isInteger(n) || n < 0) throw new Error(`--workers expects a non-negative integer, got "${raw}"`);
232
+ flags.workers = n;
233
+ }
193
234
  else if (a === "--since") flags.since = next();
194
235
  else if (a === "--config") flags.config = resolve(next());
195
236
  else if (a === "--limit") flags.limit = num();
@@ -197,6 +238,11 @@ function parseFlags(args: string[]): CliFlags {
197
238
  else if (a === "--semantic") flags.semantic = true;
198
239
  else if (a === "--recall") flags.recall = true;
199
240
  else if (a === "--run") flags.run = true;
241
+ else if (a === "--base") flags.base = next();
242
+ else if (a === "--staged") flags.staged = true;
243
+ else if (a === "--depth") flags.depth = num();
244
+ else if (a === "--kind") flags.kind = next();
245
+ else if (a === "--json") flags.json = true;
200
246
  else if (!a.startsWith("--") && flags.positional === undefined) flags.positional = a;
201
247
  else throw new Error(`unknown flag: ${a}`);
202
248
  }
@@ -238,9 +284,14 @@ const SCANLESS_COMMANDS = new Set(["grep", "churn", "coupling", "workspaces", "g
238
284
  // dispatch site). `--repo` is resolved to an absolute path and must exist: a
239
285
  // server pinned to a typo'd directory would otherwise answer every tool call
240
286
  // with the same confusing per-call error instead of failing at startup.
241
- export function parseMcpFlags(argv: string[]): { defaultRepo?: string; serverInfo?: { name?: string } } {
287
+ export function parseMcpFlags(argv: string[]): {
288
+ defaultRepo?: string;
289
+ serverInfo?: { name?: string };
290
+ maxResponseBytes?: number;
291
+ } {
242
292
  let defaultRepo: string | undefined;
243
293
  let name: string | undefined;
294
+ let maxResponseBytes: number | undefined;
244
295
  for (let i = 0; i < argv.length; i++) {
245
296
  const a = argv[i];
246
297
  if (a === "--repo") {
@@ -251,12 +302,17 @@ export function parseMcpFlags(argv: string[]): { defaultRepo?: string; serverInf
251
302
  const v = argv[++i];
252
303
  if (!v) throw new Error("--server-name requires a value");
253
304
  name = v;
305
+ } else if (a === "--max-response-bytes") {
306
+ const v = argv[++i];
307
+ const n = Number(v);
308
+ if (!v || !Number.isFinite(n) || n <= 0) throw new Error("--max-response-bytes requires a positive number");
309
+ maxResponseBytes = n;
254
310
  } else {
255
311
  throw new Error(`unknown flag for \`mcp\`: ${a}`);
256
312
  }
257
313
  }
258
314
  if (defaultRepo && !existsSync(defaultRepo)) throw new Error(`--repo path does not exist: ${defaultRepo}`);
259
- return { defaultRepo, serverInfo: name ? { name } : undefined };
315
+ return { defaultRepo, serverInfo: name ? { name } : undefined, maxResponseBytes };
260
316
  }
261
317
 
262
318
  // Flags that consume the following argv element. Needed to hoist leading flags
@@ -279,6 +335,9 @@ const VALUE_FLAGS = new Set([
279
335
  "--config",
280
336
  "--limit",
281
337
  "--server-name",
338
+ "--workers",
339
+ "--index",
340
+ "--max-response-bytes",
282
341
  ]);
283
342
 
284
343
  // Accept global flags BEFORE the subcommand as well as after, so
@@ -369,6 +428,38 @@ export async function runCli(rawArgv: string[]): Promise<void> {
369
428
  await ensureGrammars(grammarKeysForExts(precomputedWalk.files.map((f) => f.ext)));
370
429
  }
371
430
 
431
+ // Read commands reuse a persisted index instead of rebuilding from scratch.
432
+ //
433
+ // Only `index` ever consulted .codeindex/; every read command (graph, symbols,
434
+ // scip, callers, search, repomap, hotspots, deadcode, complexity, risk,
435
+ // mermaid, rules) re-walked, re-read, re-hashed and re-EXTRACTED the whole
436
+ // repo on each invocation — `codeindex search` cost a full tree-sitter pass
437
+ // every time, with a fresh index sitting right next to it.
438
+ //
439
+ // cache.json turns the scan into a stat pass; when the freshness guard holds,
440
+ // graph.json/symbols.json come back without running the pipeline at all. Both
441
+ // degrade to today's cold path when the index is absent, stale or corrupt, so
442
+ // output is unchanged either way. Resolved lazily and at most once: a command
443
+ // uses either the scan or the artifacts, never both.
444
+ const indexDir = flags.indexDir ?? INDEX_DIR;
445
+ let preloadTried = false;
446
+ let preloaded: { scan: RepoScan; arts?: IndexArtifacts } | undefined;
447
+ const tryPreload = (): { scan: RepoScan; arts?: IndexArtifacts } | undefined => {
448
+ if (preloadTried) return preloaded;
449
+ preloadTried = true;
450
+ if (flags.noIndexCache) return undefined;
451
+ const p = preloadSession(flags.repo, scanOptions(flags, precomputedWalk), indexDir);
452
+ if (p) preloaded = { scan: p.scan, arts: p.arts };
453
+ return preloaded;
454
+ };
455
+ const readScan = (): RepoScan => tryPreload()?.scan ?? scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
456
+ const readArtifacts = (): IndexArtifacts => {
457
+ const p = tryPreload();
458
+ if (p?.arts) return p.arts;
459
+ if (p) return buildArtifactsFromScan(p.scan, scanOptions(flags, precomputedWalk));
460
+ return buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
461
+ };
462
+
372
463
  if (cmd === "index") {
373
464
  if (!flags.out) throw new Error("index needs --out <dir>");
374
465
  const outDir = flags.out;
@@ -410,7 +501,12 @@ export async function runCli(rawArgv: string[]): Promise<void> {
410
501
  } catch {
411
502
  // no cache yet (or unreadable) — cold build
412
503
  }
413
- const scan = scanRepo(flags.repo, { ...scanOptions(flags, precomputedWalk), cache, out: outDir });
504
+ const scan = await scanRepoParallel(flags.repo, {
505
+ ...scanOptions(flags, precomputedWalk),
506
+ cache,
507
+ out: outDir,
508
+ workers: flags.workers,
509
+ });
414
510
  const modelDir = resolveEmbedModelDir(flags.repo);
415
511
  const model = modelDir ? loadEmbedModel(modelDir) : undefined;
416
512
 
@@ -512,23 +608,26 @@ export async function runCli(rawArgv: string[]): Promise<void> {
512
608
  process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
513
609
  }
514
610
  } else if (cmd === "scan") {
515
- const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
611
+ // Summary-only: a file count and a language histogram need the walk and the
612
+ // path-based classifiers, never a read or a parse. Same numbers as before by
613
+ // construction — scanSummary and scanRepo share the keptFiles loop.
614
+ const s = scanSummary(flags.repo, scanOptions(flags, precomputedWalk));
516
615
  const summary = {
517
616
  engineVersion: ENGINE_VERSION,
518
- commit: scan.commit,
519
- fileCount: scan.files.length,
520
- languages: scan.languages,
521
- capped: scan.capped,
617
+ commit: s.commit,
618
+ fileCount: s.fileCount,
619
+ languages: s.languages,
620
+ capped: s.capped,
522
621
  };
523
622
  emit(JSON.stringify(summary, null, 2) + "\n", flags.out);
524
623
  } else if (cmd === "graph") {
525
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
624
+ const { graph } = readArtifacts();
526
625
  emit(renderGraphJson(graph), flags.out);
527
626
  } else if (cmd === "symbols") {
528
- const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
627
+ const { symbols } = readArtifacts();
529
628
  emit(renderSymbolsJson(symbols), flags.out);
530
629
  } else if (cmd === "scip") {
531
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
630
+ const scan = readScan();
532
631
  const bytes = renderScip(scan, { projectRoot: flags.projectRoot });
533
632
  const out = flags.out ?? resolve("index.scip");
534
633
  if (out === "-") process.stdout.write(Buffer.from(bytes));
@@ -537,14 +636,14 @@ export async function runCli(rawArgv: string[]): Promise<void> {
537
636
  process.stderr.write(`codeindex: SCIP index → ${out} (${bytes.length} bytes)\n`);
538
637
  }
539
638
  } else if (cmd === "callers") {
540
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
639
+ const scan = readScan();
541
640
  const index = buildCallerIndex(scan, undefined, { recall: flags.recall });
542
641
  const obj: Record<string, unknown> = {};
543
642
  for (const [name, entry] of index) obj[name] = entry;
544
643
  emit(JSON.stringify(obj, null, 2) + "\n", flags.out);
545
644
  } else if (cmd === "search") {
546
645
  if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
547
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
646
+ const scan = readScan();
548
647
  if (flags.semantic) {
549
648
  const endpoint = resolveEmbedEndpoint();
550
649
  const lexical = (): void => {
@@ -649,7 +748,7 @@ export async function runCli(rawArgv: string[]): Promise<void> {
649
748
  }
650
749
  const model = loadEmbedModel(modelDir)!;
651
750
  mkdirSync(flags.out, { recursive: true });
652
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
751
+ const scan = readScan();
653
752
  const index = buildEmbeddingIndex(scan, model);
654
753
  writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
655
754
  process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
@@ -721,7 +820,7 @@ export async function runCli(rawArgv: string[]): Promise<void> {
721
820
  } else if (cmd === "rules") {
722
821
  if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
723
822
  const rules = parseRules(JSON.parse(readFileSync(flags.config, "utf8")));
724
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
823
+ const { graph } = readArtifacts();
725
824
  const violations = checkRules(graph, rules);
726
825
  const errors = violations.filter((v) => v.severity === "error").length;
727
826
  emit(JSON.stringify({ errors, warnings: violations.length - errors, violations }, null, 2) + "\n", flags.out);
@@ -742,26 +841,48 @@ export async function runCli(rawArgv: string[]): Promise<void> {
742
841
  for (const k of [...churn.keys()].sort()) sorted[k] = churn.get(k)!;
743
842
  emit(JSON.stringify({ ok, churn: sorted }, null, 2) + "\n", flags.out);
744
843
  } else if (cmd === "repomap") {
745
- const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
844
+ const { scan, graph } = readArtifacts();
746
845
  emit(renderRepoMap(scan, graph, { budgetTokens: flags.budgetTokens }), flags.out);
747
846
  } else if (cmd === "hotspots") {
748
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
847
+ const scan = readScan();
749
848
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
750
849
  emit(JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2) + "\n", flags.out);
751
850
  } else if (cmd === "coupling") {
752
851
  const { ok, couplings } = changeCoupling(flags.repo, { since: flags.since });
753
852
  emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags.out);
754
853
  } else if (cmd === "deadcode") {
755
- emit(JSON.stringify(findDeadCode(scanRepo(flags.repo, scanOptions(flags, precomputedWalk))), null, 2) + "\n", flags.out);
854
+ emit(JSON.stringify(findDeadCode(readScan()), null, 2) + "\n", flags.out);
756
855
  } else if (cmd === "complexity") {
757
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
856
+ const scan = readScan();
758
857
  emit(JSON.stringify(symbolComplexity(scan, flags.positional), null, 2) + "\n", flags.out);
759
858
  } else if (cmd === "risk") {
760
- const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
859
+ const scan = readScan();
761
860
  const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
762
861
  emit(JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2) + "\n", flags.out);
862
+ } else if (cmd === "delta") {
863
+ const { graph, symbols } = readArtifacts();
864
+ const res = deltaFor(flags.repo, graph, symbols, {
865
+ base: flags.base,
866
+ staged: flags.staged,
867
+ depth: flags.depth,
868
+ });
869
+ if ("error" in res) throw new Error(res.error);
870
+ emit(flags.json ? JSON.stringify(res, null, 2) + "\n" : formatDeltaPanel(res), flags.out);
871
+ } else if (cmd === "impact") {
872
+ if (!flags.positional) throw new Error("impact needs a target: cli.mjs impact <file|module> --repo <dir>");
873
+ const { graph } = readArtifacts();
874
+ const res = impactOf(graph, flags.positional, flags.depth ?? Infinity);
875
+ if (!res) throw new Error(`no such file or module in the index: ${flags.positional}`);
876
+ emit(JSON.stringify(res, null, 2) + "\n", flags.out);
877
+ } else if (cmd === "neighbors") {
878
+ if (!flags.positional) throw new Error("neighbors needs a target: cli.mjs neighbors <file|module> --repo <dir>");
879
+ const { graph } = readArtifacts();
880
+ const kinds = flags.kind ? new Set(flags.kind.split(",").map((k) => k.trim()).filter(Boolean)) : undefined;
881
+ const res = neighborsOf(graph, flags.positional, flags.depth ?? 1, kinds);
882
+ if (!res) throw new Error(`no such file or module in the index: ${flags.positional}`);
883
+ emit(JSON.stringify(res, null, 2) + "\n", flags.out);
763
884
  } else if (cmd === "mermaid") {
764
- const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
885
+ const { graph } = readArtifacts();
765
886
  emit(renderMermaid(graph, { module: flags.positional }), flags.out);
766
887
  } else if (cmd === "grep") {
767
888
  if (!flags.positional) throw new Error("grep needs a pattern: cli.mjs grep <pattern> --repo <dir>");
package/src/engine.ts CHANGED
@@ -28,8 +28,23 @@ export type {
28
28
  // Files tier: walk, read, filter, classify.
29
29
  export { walk, readText, DEFAULT_MAX_FILES } from "./walk.js";
30
30
  export type { WalkOptions, WalkedFile, WalkResult } from "./walk.js";
31
- export { scanRepo } from "./scan.js";
32
- export type { RepoScan, ScanOptions } from "./scan.js";
31
+ export { scanRepo, scanSummary } from "./scan.js";
32
+ export type { RepoScan, ScanOptions, ScanSummary, ExtractedRecord } from "./scan.js";
33
+ export { keptCodeFiles, buildCodeRecord } from "./scan.js";
34
+ // Reusing a persisted `.codeindex/` index instead of rebuilding it. The MCP
35
+ // server and every CLI read command go through this; a consumer that vendors
36
+ // the engine gets the same shortcut. Every function degrades to undefined
37
+ // (= "build it yourself") rather than throwing.
38
+ export { preloadSession, preloadArtifacts, readPersistedIndex, toCacheMap, INDEX_DIR } from "./preload.js";
39
+ export type { PersistedMeta, PersistedCacheEntry, PersistedCacheMap } from "./preload.js";
40
+ // Parallel extraction. scanRepoParallel is scanRepo with the code files
41
+ // extracted across worker_threads; it returns the same RepoScan, byte-for-byte,
42
+ // and degrades to the sequential path whenever workers are unavailable.
43
+ //
44
+ // `runExtractWorker` MUST stay exported: the worker bootstrap imports it BY NAME
45
+ // off this barrel, and a missing export is indistinguishable from "this is not
46
+ // the engine" — the pool would silently run sequential forever.
47
+ export { scanRepoParallel, extractInParallel, runExtractWorker, workerCount } from "./pool.js";
33
48
  export { compileGlobs } from "./glob.js";
34
49
  export { parseGitignore, isIgnored } from "./ignore.js";
35
50
  export type { IgnoreRule } from "./ignore.js";
@@ -177,8 +192,20 @@ export { findDeadCode } from "./deadcode.js";
177
192
  export type { DeadSymbol } from "./deadcode.js";
178
193
  export { symbolComplexity, riskHotspots, complexityOfSource } from "./complexity.js";
179
194
  export type { SymbolComplexity, RiskHotspot } from "./complexity.js";
180
- export { renderMermaid } from "./viz.js";
181
- export type { MermaidOptions } from "./viz.js";
195
+ export { renderMermaid, renderMermaidClustered } from "./viz.js";
196
+ export type { MermaidOptions, ClusteredMermaidOptions, ClusteredMermaidResult } from "./viz.js";
197
+
198
+ // Graph traversal: reverse-dependency closure ("what breaks if I change this")
199
+ // and bidirectional neighbourhood walks. Pure functions of a Graph, so a
200
+ // consumer holding a persisted graph.json answers both without a rescan.
201
+ export { impactOf, neighborsOf, reverseClosure, hubThreshold } from "./traverse.js";
202
+ export type { ImpactResult, ImpactedFile, NeighborResult, NeighborLink } from "./traverse.js";
203
+
204
+ // Diff review: git diff -> enclosing symbols -> blast radius -> risk-scored,
205
+ // reasons-first panel. `computeDelta` is the pure core (no git, no fs);
206
+ // `deltaFor` adds the git plumbing against a graph the caller supplies.
207
+ export { computeDelta, deltaFor, formatDeltaPanel, symbolsInHunks, RISK_WEIGHTS, DEFAULT_DELTA_DEPTH } from "./delta.js";
208
+ export type { DeltaOptions, DeltaResult, DeltaError, DeltaModule, DeltaChange, ChangedSymbol } from "./delta.js";
182
209
  export type { RepoMapOptions } from "./repomap.js";
183
210
 
184
211
  // MCP server over stdio (also reachable as `engine.mjs mcp`).
@@ -360,7 +360,10 @@ export function extractCode(rel: string, ext: string, content: string, opts: { m
360
360
  // extractors. Imports/pkg stay on the battle-tested regex path here — their
361
361
  // resolution is covered by resolve tests and the e2e ratchet; the new-language
362
362
  // AST importers land with their resolvers.
363
- const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile });
363
+ // `imports: false` because of exactly that: `ast.refs` and `ast.pkg` were
364
+ // computed by a full extra tree traversal and then discarded right below, in
365
+ // favour of the regex results. Public `extractAst` still computes them.
366
+ const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile, imports: false });
364
367
  const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
365
368
  // Add barrel re-exports the local def didn't already cover.
366
369
  const known = new Set(symbols.map((s) => s.name));
package/src/graph.ts CHANGED
Binary file
@@ -0,0 +1,176 @@
1
+ // MCP wire concerns: protocol-version negotiation, argument validation, and the
2
+ // response-size guard. Everything here is a pure function of its inputs — no
3
+ // scan, no filesystem beyond checking whether a persisted artifact exists — so
4
+ // it is unit-testable without standing up a server.
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { INDEX_DIR } from "../preload.js";
9
+
10
+ // --- protocol versions -------------------------------------------------------
11
+ // The server announced "2024-11-05" hard-coded and never even read the version
12
+ // the client asked for. Three revisions have shipped since.
13
+ //
14
+ // Negotiation is what makes moving forward non-breaking: a client that asks for
15
+ // an old revision gets that revision, and every field introduced later is
16
+ // withheld — so its responses are exactly the bytes it received before. Newer
17
+ // clients opt themselves in simply by asking.
18
+ //
19
+ // Dates sort lexicographically, so `>=` on the strings is a version comparison.
20
+ export const PROTOCOL_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] as const;
21
+ const LATEST_PROTOCOL = PROTOCOL_VERSIONS[PROTOCOL_VERSIONS.length - 1]!;
22
+
23
+ // Feature floors, by the revision that introduced them.
24
+ export const ANNOTATIONS_SINCE = "2025-03-26"; // tool behaviour hints
25
+ export const RICH_TOOLS_SINCE = "2025-06-18"; // Tool.title, resource_link content
26
+
27
+ // Validate `arguments` against the tool's declared inputSchema.
28
+ //
29
+ // There was no validation at all beyond presence checks, and the readers failed
30
+ // silently in both directions: str() returns undefined for a non-string, so a
31
+ // number where a path belongs became "missing", and every boolean was `=== true`,
32
+ // so `"false"` and `1` alike read as false. The caller saw its option ignored
33
+ // with no way to tell why.
34
+ //
35
+ // Only the shapes these schemas actually use are checked (string / number /
36
+ // boolean / array-of-string) — this is a guard against silent misreads, not a
37
+ // JSON Schema implementation. The spec (2025-11-25) is explicit that input
38
+ // validation failures belong in a Tool Execution Error, not a protocol error,
39
+ // precisely so the model can read the message and retry.
40
+ // Required-ness stays with callTool, which raises tool-specific messages
41
+ // ("`rules` (or `configPath`) is required"); duplicating it here would only let
42
+ // the two drift.
43
+ export function validateArgs(
44
+ schema: { properties?: Record<string, unknown> },
45
+ args: Record<string, unknown>,
46
+ ): string | undefined {
47
+ const props = (schema.properties ?? {}) as Record<string, { type?: string; items?: { type?: string } }>;
48
+ for (const [key, value] of Object.entries(args)) {
49
+ if (value === undefined || value === null) continue;
50
+ const spec = props[key];
51
+ if (!spec?.type) continue; // undeclared extras stay tolerated
52
+ const actual = Array.isArray(value) ? "array" : typeof value;
53
+ if (spec.type === "number") {
54
+ // A numeric string is accepted (num() coerces it); anything else is not.
55
+ if (actual === "number") continue;
56
+ if (actual === "string" && Number.isFinite(Number(value as string)) && (value as string).trim() !== "") continue;
57
+ return `\`${key}\` must be a number, got ${actual === "string" ? JSON.stringify(value) : actual}`;
58
+ }
59
+ if (spec.type === "array") {
60
+ if (actual !== "array") return `\`${key}\` must be an array of strings, got ${actual}`;
61
+ if (spec.items?.type === "string" && !(value as unknown[]).every((x) => typeof x === "string")) {
62
+ return `\`${key}\` must be an array of strings`;
63
+ }
64
+ continue;
65
+ }
66
+ if (actual !== spec.type) return `\`${key}\` must be a ${spec.type}, got ${actual}`;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ // The structuredContent for a tool response, or undefined when there must not
72
+ // be one.
73
+ //
74
+ // Emitted only when ALL of these hold, because the spec requires a declared
75
+ // outputSchema to be honoured by every structured result:
76
+ // * the tool declares an outputSchema (see OUTPUT_SCHEMAS),
77
+ // * the response was NOT replaced by the size guard — the truncation notice
78
+ // is a different shape and would not conform,
79
+ // * the text parses to a JSON object (never an array: structuredContent is
80
+ // specified as an object).
81
+ // The text block is left exactly as it was, so this is purely additive and
82
+ // content stays the serialization of structuredContent, as the spec asks.
83
+ export function structuredContentFor(text: string, capped: boolean, hasSchema: boolean): Record<string, unknown> | undefined {
84
+ if (capped || !hasSchema) return undefined;
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(text);
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
92
+ return parsed as Record<string, unknown>;
93
+ }
94
+
95
+ export function negotiateProtocol(requested: unknown): string {
96
+ return typeof requested === "string" && (PROTOCOL_VERSIONS as readonly string[]).includes(requested)
97
+ ? requested
98
+ : LATEST_PROTOCOL;
99
+ }
100
+
101
+ // --- response size guard -----------------------------------------------------
102
+ // Several tools returned unbounded payloads. On facebook/react (7091 files):
103
+ // graph 9.4 MB, symbols 6.3 MB, callers 6.0 MB, dead_code 771 KB — roughly
104
+ // 2.35M, 1.57M, 1.51M and 193k tokens. A single `graph` call does not merely
105
+ // bloat an agent's context, it exceeds what any MCP client can accept, so the
106
+ // call fails and the turn is wasted.
107
+ //
108
+ // The guard is deliberately NOT a default page size: below the limit a response
109
+ // is byte-identical to what it always was. Above it, the response could not be
110
+ // consumed by any client anyway, so replacing it with something actionable
111
+ // cannot regress a working call — it converts a hard failure into a usable
112
+ // answer that says how big the payload is, where the artifact already sits on
113
+ // disk, and which narrower tool answers the question.
114
+ export const DEFAULT_MAX_RESPONSE_BYTES = 1_000_000;
115
+
116
+ // What to steer a caller toward when their whole-repo request is too large.
117
+ const NARROWER: Record<string, string> = {
118
+ graph: "pass `scope` to a subdirectory, or use repo_map / mermaid for an overview",
119
+ symbols: "pass `name` to look up one symbol, or use find_symbol / symbols_overview",
120
+ callers: "pass `name` to look up one symbol's call sites",
121
+ dead_code: "pass `scope` to a subdirectory",
122
+ find_references: "the symbol is referenced very widely — narrow with `scope` on a graph query",
123
+ check_rules: "narrow the rule set, or pass `scope` to a subdirectory",
124
+ };
125
+
126
+ // The persisted artifact backing a tool, when a `codeindex index` already wrote
127
+ // one — far more useful to hand back than a truncated blob.
128
+ const ARTIFACT_FOR: Record<string, string> = { graph: "graph.json", symbols: "symbols.json" };
129
+
130
+ export function capResponse(text: string, tool: string, repo: string, maxBytes: number): string {
131
+ const bytes = Buffer.byteLength(text, "utf8");
132
+ if (bytes <= maxBytes) return text;
133
+ const artifact = ARTIFACT_FOR[tool] ? join(repo, INDEX_DIR, ARTIFACT_FOR[tool]!) : undefined;
134
+ return (
135
+ JSON.stringify(
136
+ {
137
+ truncated: true,
138
+ tool,
139
+ bytes,
140
+ maxBytes,
141
+ reason:
142
+ "This response exceeds the configured limit and was withheld rather than sent as an unusable partial payload.",
143
+ narrower: NARROWER[tool] ?? "narrow the request with `scope`, `include`/`exclude`, or a `limit`",
144
+ ...(artifact && existsSync(artifact)
145
+ ? { artifact, artifactNote: "The full result is already on disk here — read it directly if you need all of it." }
146
+ : artifact
147
+ ? { artifactNote: `Run \`codeindex index --repo ${repo} --out ${join(repo, INDEX_DIR)}\` to get this as a file.` }
148
+ : {}),
149
+ },
150
+ null,
151
+ 2,
152
+ ) + "\n"
153
+ );
154
+ }
155
+
156
+ // When capResponse withheld a payload AND the artifact is on disk, hand the
157
+ // client a resource_link to it. Returns undefined for every normal response —
158
+ // this only ever adds a second content block to a capped one.
159
+ export function resourceLinkFor(text: string, tool: string): Record<string, unknown> | undefined {
160
+ const artifactName = ARTIFACT_FOR[tool];
161
+ if (!artifactName) return undefined;
162
+ let parsed: { truncated?: boolean; artifact?: string };
163
+ try {
164
+ parsed = JSON.parse(text) as typeof parsed;
165
+ } catch {
166
+ return undefined; // a normal (non-JSON, or non-capped) response
167
+ }
168
+ if (parsed.truncated !== true || typeof parsed.artifact !== "string") return undefined;
169
+ return {
170
+ type: "resource_link",
171
+ uri: pathToFileURL(parsed.artifact).href,
172
+ name: artifactName,
173
+ description: `The full ${tool} result this call was too large to inline.`,
174
+ mimeType: "application/json",
175
+ };
176
+ }