@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/README.md +32 -3
- package/docs/MIGRATION.md +127 -0
- package/package.json +1 -1
- package/scripts/engine.d.mts +242 -18
- package/scripts/engine.mjs +2274 -1084
- package/src/ast/extract.ts +180 -142
- package/src/coupling.ts +0 -0
- package/src/delta.ts +417 -0
- package/src/derived.ts +17 -0
- package/src/engine-cli.ts +145 -24
- package/src/engine.ts +31 -4
- package/src/extract/code.ts +4 -1
- package/src/graph.ts +0 -0
- package/src/mcp/protocol.ts +176 -0
- package/src/mcp/session.ts +287 -0
- package/src/mcp/tools.ts +541 -0
- package/src/mcp.ts +176 -668
- package/src/pool.ts +271 -0
- package/src/preload.ts +159 -0
- package/src/render/scip.ts +80 -20
- package/src/render/symbols-json.ts +3 -2
- package/src/scan.ts +142 -17
- package/src/traverse.ts +164 -0
- package/src/types.ts +1 -1
- package/src/viz.ts +91 -1
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[]): {
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
519
|
-
fileCount:
|
|
520
|
-
languages:
|
|
521
|
-
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 } =
|
|
624
|
+
const { graph } = readArtifacts();
|
|
526
625
|
emit(renderGraphJson(graph), flags.out);
|
|
527
626
|
} else if (cmd === "symbols") {
|
|
528
|
-
const { symbols } =
|
|
627
|
+
const { symbols } = readArtifacts();
|
|
529
628
|
emit(renderSymbolsJson(symbols), flags.out);
|
|
530
629
|
} else if (cmd === "scip") {
|
|
531
|
-
const scan =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 } =
|
|
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 } =
|
|
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 =
|
|
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(
|
|
854
|
+
emit(JSON.stringify(findDeadCode(readScan()), null, 2) + "\n", flags.out);
|
|
756
855
|
} else if (cmd === "complexity") {
|
|
757
|
-
const scan =
|
|
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 =
|
|
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 } =
|
|
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`).
|
package/src/extract/code.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|