@maxgfr/codeindex 2.13.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -10
- package/docs/MIGRATION.md +170 -36
- package/package.json +1 -1
- package/scripts/engine.d.mts +39 -4
- package/scripts/engine.mjs +1229 -721
- package/src/ast/grammars-pull.ts +178 -0
- package/src/ast/loader.ts +77 -15
- package/src/bm25.ts +16 -6
- package/src/callers.ts +10 -14
- package/src/complexity.ts +7 -1
- package/src/deadcode.ts +5 -4
- package/src/derived.ts +128 -0
- package/src/engine-cli.ts +288 -46
- package/src/engine.ts +20 -3
- package/src/hash.ts +5 -2
- package/src/mcp.ts +323 -24
- package/src/pipeline.ts +16 -5
- package/src/query.ts +8 -5
- package/src/scan.ts +66 -8
- package/src/types.ts +1 -1
- package/src/walk.ts +26 -9
package/src/engine-cli.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { SCHEMA_VERSION, EXTRACTOR_VERSION, type FileRecord } from "./types.js";
|
|
4
4
|
import { ENGINE_VERSION } from "./types.js";
|
|
5
|
-
import { ensureGrammars,
|
|
6
|
-
import {
|
|
5
|
+
import { ensureGrammars, grammarKeysForExts, resolveGrammarsTier, sharedGrammarsCacheDir } from "./ast/loader.js";
|
|
6
|
+
import {
|
|
7
|
+
resolveGrammarsPullTarget,
|
|
8
|
+
fetchGrammarsTarball,
|
|
9
|
+
fetchExpectedSha256,
|
|
10
|
+
extractGrammarsTarball,
|
|
11
|
+
} from "./ast/grammars-pull.js";
|
|
12
|
+
import { buildIndexArtifacts, buildArtifactsFromScan, type BuildIndexOptions } from "./pipeline.js";
|
|
13
|
+
import { sha1 } from "./hash.js";
|
|
7
14
|
import { renderGraphJson } from "./render/graph-json.js";
|
|
8
15
|
import { renderSymbolsJson } from "./render/symbols-json.js";
|
|
9
16
|
import { renderScip } from "./render/scip.js";
|
|
10
17
|
import { scanRepo } from "./scan.js";
|
|
18
|
+
import { walk, type WalkResult } from "./walk.js";
|
|
11
19
|
import { buildCallerIndex } from "./callers.js";
|
|
12
20
|
import { detectWorkspaces } from "./workspaces.js";
|
|
13
21
|
import { gitChurn } from "./git.js";
|
|
@@ -60,6 +68,14 @@ Commands:
|
|
|
60
68
|
the source with CODEINDEX_EMBED_URL
|
|
61
69
|
embed serve Print (or --run) the docker command that starts the
|
|
62
70
|
containerized embedding server (rich tier)
|
|
71
|
+
grammars Tree-sitter wasm grammars (optional AST tier; regex without them).
|
|
72
|
+
Precedence: bundle-adjacent > CODEINDEX_GRAMMARS_DIR > shared cache:
|
|
73
|
+
grammars status Active tier (adjacent/env/cache/none), resolved
|
|
74
|
+
dir, pinned ENGINE_VERSION, pull-needed (JSON)
|
|
75
|
+
grammars pull Fetch the per-release grammars-<version>.tar.gz
|
|
76
|
+
asset into the shared cache (sha256-verified,
|
|
77
|
+
atomic). Override the source with
|
|
78
|
+
CODEINDEX_GRAMMARS_URL
|
|
63
79
|
rules Architecture rules (forbidden edges, cycles, orphans) validated
|
|
64
80
|
against the link-graph: --config <codeindex.rules.json>; exits 1
|
|
65
81
|
on any error-severity violation (a CI gate)
|
|
@@ -175,7 +191,7 @@ function emit(content: string, out?: string): void {
|
|
|
175
191
|
else process.stdout.write(content);
|
|
176
192
|
}
|
|
177
193
|
|
|
178
|
-
function scanOptions(flags: CliFlags): BuildIndexOptions {
|
|
194
|
+
function scanOptions(flags: CliFlags, precomputedWalk?: WalkResult): BuildIndexOptions {
|
|
179
195
|
return {
|
|
180
196
|
include: flags.include.length ? flags.include : undefined,
|
|
181
197
|
exclude: flags.exclude.length ? flags.exclude : undefined,
|
|
@@ -185,9 +201,22 @@ function scanOptions(flags: CliFlags): BuildIndexOptions {
|
|
|
185
201
|
maxFiles: flags.maxFiles,
|
|
186
202
|
maxBytes: flags.maxBytes,
|
|
187
203
|
maxCallsPerFile: flags.maxCalls,
|
|
204
|
+
// The walk performed once in runCli to warm the present-language grammars,
|
|
205
|
+
// reused here so scanRepo does not traverse the tree a second time. Absent
|
|
206
|
+
// for --no-ast / scan-less commands: scanRepo walks itself, unchanged.
|
|
207
|
+
precomputedWalk,
|
|
188
208
|
};
|
|
189
209
|
}
|
|
190
210
|
|
|
211
|
+
// Commands that never walk/scan the file tree — they read git/grep directly or
|
|
212
|
+
// only manage the grammar cache, so they must warm NO tree-sitter grammar (the
|
|
213
|
+
// CLI previously warmed every one unconditionally). `embed` is scan-only for its
|
|
214
|
+
// `build` subcommand; the other embed subcommands (status/pull/serve) are
|
|
215
|
+
// excluded by the positional check at the warm site. `grammars` (status/pull)
|
|
216
|
+
// resolves/downloads the wasms itself and must not warm them.
|
|
217
|
+
// version/help/mcp return before we get there.
|
|
218
|
+
const SCANLESS_COMMANDS = new Set(["grep", "churn", "coupling", "workspaces", "grammars"]);
|
|
219
|
+
|
|
191
220
|
export async function runCli(argv: string[]): Promise<void> {
|
|
192
221
|
const [cmd, ...rest] = argv;
|
|
193
222
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
@@ -206,7 +235,25 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
206
235
|
|
|
207
236
|
const flags = parseFlags(rest);
|
|
208
237
|
if (!existsSync(flags.repo)) throw new Error(`--repo path does not exist: ${flags.repo}`);
|
|
209
|
-
|
|
238
|
+
|
|
239
|
+
// Warm ONLY the grammars for languages actually present, and only for commands
|
|
240
|
+
// that scan the file tree. Scan-less commands (grep, churn, coupling,
|
|
241
|
+
// workspaces, embed status|pull|serve) load no grammar at all; version/help/mcp
|
|
242
|
+
// already returned above. The walk is done ONCE here to derive the present
|
|
243
|
+
// extensions, then handed to the scan via precomputedWalk so the tree is
|
|
244
|
+
// traversed a single time. --no-ast keeps the regex tier: no walk, no warm —
|
|
245
|
+
// scanRepo walks itself, exactly as before.
|
|
246
|
+
const scans = !SCANLESS_COMMANDS.has(cmd) && !(cmd === "embed" && flags.positional !== "build");
|
|
247
|
+
let precomputedWalk: WalkResult | undefined;
|
|
248
|
+
if (scans && !flags.noAst) {
|
|
249
|
+
precomputedWalk = walk(flags.repo, {
|
|
250
|
+
maxFileBytes: flags.maxBytes,
|
|
251
|
+
maxFiles: flags.maxFiles,
|
|
252
|
+
gitignore: flags.gitignore,
|
|
253
|
+
ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
|
|
254
|
+
});
|
|
255
|
+
await ensureGrammars(grammarKeysForExts(precomputedWalk.files.map((f) => f.ext)));
|
|
256
|
+
}
|
|
210
257
|
|
|
211
258
|
if (cmd === "index") {
|
|
212
259
|
if (!flags.out) throw new Error("index needs --out <dir>");
|
|
@@ -215,47 +262,143 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
215
262
|
// Incremental cache: reuse per-file records when (schema, extractor) match —
|
|
216
263
|
// same invalidation discipline as ultraindex's cache.json.
|
|
217
264
|
const cachePath = join(outDir, "cache.json");
|
|
218
|
-
|
|
265
|
+
type CacheEntry = { hash: string; record: FileRecord; size?: number; mtimeMs?: number };
|
|
266
|
+
// ADDITIVE meta keys describing the artifacts the cache-writing run put on
|
|
267
|
+
// disk. Old engines ignore them (they only check schema/extractor above);
|
|
268
|
+
// old caches lacking them simply never take the fastpath below (their
|
|
269
|
+
// per-file records are still reused). cache.json embeds mtimes, so it was
|
|
270
|
+
// never cross-machine byte-reproducible — no determinism surface changes.
|
|
271
|
+
type CacheMeta = {
|
|
272
|
+
engineVersion?: string;
|
|
273
|
+
commit?: string;
|
|
274
|
+
graphSha1?: string;
|
|
275
|
+
symbolsSha1?: string;
|
|
276
|
+
embed?: { embedVersion?: number; modelId?: string; sha1?: string };
|
|
277
|
+
};
|
|
278
|
+
let cache: Map<string, CacheEntry> | undefined;
|
|
279
|
+
let meta: CacheMeta = {};
|
|
219
280
|
try {
|
|
220
281
|
const parsed = JSON.parse(readFileSync(cachePath, "utf8")) as {
|
|
221
282
|
schemaVersion: number;
|
|
222
283
|
extractorVersion: number;
|
|
223
|
-
files: Record<string,
|
|
224
|
-
};
|
|
284
|
+
files: Record<string, CacheEntry>;
|
|
285
|
+
} & CacheMeta;
|
|
225
286
|
if (parsed.schemaVersion === SCHEMA_VERSION && parsed.extractorVersion === EXTRACTOR_VERSION) {
|
|
226
287
|
cache = new Map(Object.entries(parsed.files));
|
|
288
|
+
meta = {
|
|
289
|
+
engineVersion: parsed.engineVersion,
|
|
290
|
+
commit: parsed.commit,
|
|
291
|
+
graphSha1: parsed.graphSha1,
|
|
292
|
+
symbolsSha1: parsed.symbolsSha1,
|
|
293
|
+
embed: parsed.embed,
|
|
294
|
+
};
|
|
227
295
|
}
|
|
228
296
|
} catch {
|
|
229
297
|
// no cache yet (or unreadable) — cold build
|
|
230
298
|
}
|
|
231
|
-
const
|
|
232
|
-
writeFileSync(join(outDir, "graph.json"), renderGraphJson(graph));
|
|
233
|
-
writeFileSync(join(outDir, "symbols.json"), renderSymbolsJson(symbols));
|
|
234
|
-
const files: Record<string, { hash: string; record: FileRecord; size?: number; mtimeMs?: number }> = {};
|
|
235
|
-
for (const f of scan.files) {
|
|
236
|
-
const entry: { hash: string; record: FileRecord; size?: number; mtimeMs?: number } = { hash: f.hash, record: f, size: f.size };
|
|
237
|
-
const mtime = scan.mtimes.get(f.rel);
|
|
238
|
-
if (mtime !== undefined) entry.mtimeMs = mtime;
|
|
239
|
-
files[f.rel] = entry;
|
|
240
|
-
}
|
|
241
|
-
writeFileSync(
|
|
242
|
-
cachePath,
|
|
243
|
-
JSON.stringify({ schemaVersion: SCHEMA_VERSION, extractorVersion: EXTRACTOR_VERSION, files }) + "\n",
|
|
244
|
-
);
|
|
245
|
-
// Deterministic embeddings sidecar: written next to graph.json ONLY when a
|
|
246
|
-
// model asset is present (opt-in). Silently skipped otherwise — no model, no
|
|
247
|
-
// embeddings.bin, no impact on the graph/symbols consumers.
|
|
248
|
-
let embedNote = "";
|
|
299
|
+
const scan = scanRepo(flags.repo, { ...scanOptions(flags, precomputedWalk), cache, out: outDir });
|
|
249
300
|
const modelDir = resolveEmbedModelDir(flags.repo);
|
|
250
301
|
const model = modelDir ? loadEmbedModel(modelDir) : undefined;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
302
|
+
|
|
303
|
+
const graphPath = join(outDir, "graph.json");
|
|
304
|
+
const symbolsPath = join(outDir, "symbols.json");
|
|
305
|
+
const embedPath = join(outDir, "embeddings.bin");
|
|
306
|
+
// sha of an on-disk artifact, or undefined when it is missing/unreadable —
|
|
307
|
+
// never equal to a defined meta sha, so a deleted artifact fails the guard.
|
|
308
|
+
const artifactSha = (path: string): string | undefined => {
|
|
309
|
+
try {
|
|
310
|
+
return sha1(readFileSync(path));
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const writeCache = (out: Pick<CacheMeta, "graphSha1" | "symbolsSha1" | "embed">): void => {
|
|
316
|
+
const files: Record<string, CacheEntry> = {};
|
|
317
|
+
for (const f of scan.files) {
|
|
318
|
+
const entry: CacheEntry = { hash: f.hash, record: f, size: f.size };
|
|
319
|
+
const mtime = scan.mtimes.get(f.rel);
|
|
320
|
+
if (mtime !== undefined) entry.mtimeMs = mtime;
|
|
321
|
+
files[f.rel] = entry;
|
|
322
|
+
}
|
|
323
|
+
// Fixed key order; JSON.stringify drops the undefined-valued keys
|
|
324
|
+
// (commit outside a git worktree, embed without a model) cleanly.
|
|
325
|
+
writeFileSync(
|
|
326
|
+
cachePath,
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
schemaVersion: SCHEMA_VERSION,
|
|
329
|
+
extractorVersion: EXTRACTOR_VERSION,
|
|
330
|
+
engineVersion: ENGINE_VERSION,
|
|
331
|
+
commit: scan.commit,
|
|
332
|
+
graphSha1: out.graphSha1,
|
|
333
|
+
symbolsSha1: out.symbolsSha1,
|
|
334
|
+
embed: out.embed,
|
|
335
|
+
files,
|
|
336
|
+
}) + "\n",
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// FASTPATH GUARD — skip the whole downstream pipeline only when this scan
|
|
341
|
+
// is proven identical to the run that wrote the on-disk artifacts:
|
|
342
|
+
// contentUnchanged means this scan's records are object-identical to that
|
|
343
|
+
// run's; downstream is a pure function of (records, docText, commit,
|
|
344
|
+
// meta-opts) and the CLI never sets meta/previousCommunities;
|
|
345
|
+
// engineVersion pins the version stamp; commit must match because
|
|
346
|
+
// graph.json embeds it (identical trees under a new HEAD must rebuild);
|
|
347
|
+
// the shas prove the on-disk bytes are that run's output. ANY failure —
|
|
348
|
+
// deleted or tampered artifacts included — falls through to the full
|
|
349
|
+
// build, which rewrites everything (self-healing).
|
|
350
|
+
const embedUnchanged =
|
|
351
|
+
!model ||
|
|
352
|
+
(meta.embed !== undefined &&
|
|
353
|
+
meta.embed.embedVersion === EMBED_VERSION &&
|
|
354
|
+
meta.embed.modelId === model.modelId &&
|
|
355
|
+
meta.embed.sha1 !== undefined &&
|
|
356
|
+
artifactSha(embedPath) === meta.embed.sha1);
|
|
357
|
+
const fastpath =
|
|
358
|
+
scan.contentUnchanged &&
|
|
359
|
+
meta.engineVersion === ENGINE_VERSION &&
|
|
360
|
+
meta.commit === scan.commit &&
|
|
361
|
+
meta.graphSha1 !== undefined &&
|
|
362
|
+
artifactSha(graphPath) === meta.graphSha1 &&
|
|
363
|
+
meta.symbolsSha1 !== undefined &&
|
|
364
|
+
artifactSha(symbolsPath) === meta.symbolsSha1 &&
|
|
365
|
+
embedUnchanged;
|
|
366
|
+
|
|
367
|
+
if (fastpath) {
|
|
368
|
+
// Artifacts verified byte-identical to what this build would produce —
|
|
369
|
+
// leave them untouched. Rewrite cache.json only when the scan says its
|
|
370
|
+
// bytes would change (e.g. an mtime drifted); the meta is carried
|
|
371
|
+
// forward verbatim since the guard just proved it describes the disk.
|
|
372
|
+
if (scan.cacheDirty) writeCache(meta);
|
|
373
|
+
process.stderr.write(
|
|
374
|
+
`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${scan.capped ? " (capped)" : ""} (unchanged — artifacts reused)\n`,
|
|
375
|
+
);
|
|
376
|
+
} else {
|
|
377
|
+
const { graph, symbols } = buildArtifactsFromScan(scan);
|
|
378
|
+
const graphJson = renderGraphJson(graph);
|
|
379
|
+
const symbolsJson = renderSymbolsJson(symbols);
|
|
380
|
+
writeFileSync(graphPath, graphJson);
|
|
381
|
+
writeFileSync(symbolsPath, symbolsJson);
|
|
382
|
+
// Deterministic embeddings sidecar: written next to graph.json ONLY when a
|
|
383
|
+
// model asset is present (opt-in). Silently skipped otherwise — no model, no
|
|
384
|
+
// embeddings.bin, no impact on the graph/symbols consumers.
|
|
385
|
+
let embedNote = "";
|
|
386
|
+
let embedMeta: CacheMeta["embed"];
|
|
387
|
+
if (model) {
|
|
388
|
+
const index = buildEmbeddingIndex(scan, model);
|
|
389
|
+
const bytes = serializeEmbeddings(index);
|
|
390
|
+
writeFileSync(embedPath, bytes);
|
|
391
|
+
embedMeta = { embedVersion: EMBED_VERSION, modelId: model.modelId, sha1: sha1(bytes) };
|
|
392
|
+
embedNote = ` + embeddings.bin (${index.records.length} records, model ${model.modelId})`;
|
|
393
|
+
}
|
|
394
|
+
// cache.json is written LAST so its meta always describes artifacts that
|
|
395
|
+
// are already on disk — a crash mid-way leaves stale meta whose shas
|
|
396
|
+
// fail the guard on the next run (safe: it just rebuilds).
|
|
397
|
+
writeCache({ graphSha1: sha1(graphJson), symbolsSha1: sha1(symbolsJson), embed: embedMeta });
|
|
398
|
+
process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
|
|
255
399
|
}
|
|
256
|
-
process.stderr.write(`codeindex: ${scan.files.length} files → ${outDir}/graph.json + symbols.json${embedNote}${scan.capped ? " (capped)" : ""}\n`);
|
|
257
400
|
} else if (cmd === "scan") {
|
|
258
|
-
const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
401
|
+
const { scan } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
259
402
|
const summary = {
|
|
260
403
|
engineVersion: ENGINE_VERSION,
|
|
261
404
|
commit: scan.commit,
|
|
@@ -265,13 +408,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
265
408
|
};
|
|
266
409
|
emit(JSON.stringify(summary, null, 2) + "\n", flags.out);
|
|
267
410
|
} else if (cmd === "graph") {
|
|
268
|
-
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
411
|
+
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
269
412
|
emit(renderGraphJson(graph), flags.out);
|
|
270
413
|
} else if (cmd === "symbols") {
|
|
271
|
-
const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
414
|
+
const { symbols } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
272
415
|
emit(renderSymbolsJson(symbols), flags.out);
|
|
273
416
|
} else if (cmd === "scip") {
|
|
274
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
417
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
275
418
|
const bytes = renderScip(scan, { projectRoot: flags.projectRoot });
|
|
276
419
|
const out = flags.out ?? resolve("index.scip");
|
|
277
420
|
if (out === "-") process.stdout.write(Buffer.from(bytes));
|
|
@@ -280,14 +423,14 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
280
423
|
process.stderr.write(`codeindex: SCIP index → ${out} (${bytes.length} bytes)\n`);
|
|
281
424
|
}
|
|
282
425
|
} else if (cmd === "callers") {
|
|
283
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
426
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
284
427
|
const index = buildCallerIndex(scan, undefined, { recall: flags.recall });
|
|
285
428
|
const obj: Record<string, unknown> = {};
|
|
286
429
|
for (const [name, entry] of index) obj[name] = entry;
|
|
287
430
|
emit(JSON.stringify(obj, null, 2) + "\n", flags.out);
|
|
288
431
|
} else if (cmd === "search") {
|
|
289
432
|
if (!flags.positional) throw new Error('search needs a query: cli.mjs search "<query>" --repo <dir>');
|
|
290
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
433
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
291
434
|
if (flags.semantic) {
|
|
292
435
|
const endpoint = resolveEmbedEndpoint();
|
|
293
436
|
const lexical = (): void => {
|
|
@@ -392,7 +535,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
392
535
|
}
|
|
393
536
|
const model = loadEmbedModel(modelDir)!;
|
|
394
537
|
mkdirSync(flags.out, { recursive: true });
|
|
395
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
538
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
396
539
|
const index = buildEmbeddingIndex(scan, model);
|
|
397
540
|
writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
|
|
398
541
|
process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
|
|
@@ -429,10 +572,109 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
429
572
|
} else {
|
|
430
573
|
throw new Error("embed needs a subcommand: status | build | pull | serve");
|
|
431
574
|
}
|
|
575
|
+
} else if (cmd === "grammars") {
|
|
576
|
+
const sub = flags.positional;
|
|
577
|
+
const cacheDir = sharedGrammarsCacheDir();
|
|
578
|
+
if (sub === "status") {
|
|
579
|
+
// Report which tier furnishes the wasms (adjacent/env/cache/none), the
|
|
580
|
+
// resolved dir, the pinned ENGINE_VERSION the cache is keyed on, and
|
|
581
|
+
// whether a pull is needed (no runtime wasm resolvable → AST off, regex).
|
|
582
|
+
const info = resolveGrammarsTier();
|
|
583
|
+
const runtimePresent = info.dir ? existsSync(join(info.dir, "web-tree-sitter.wasm")) : false;
|
|
584
|
+
const target = resolveGrammarsPullTarget();
|
|
585
|
+
const status: Record<string, unknown> = {
|
|
586
|
+
engineVersion: ENGINE_VERSION,
|
|
587
|
+
tier: info.tier,
|
|
588
|
+
dir: info.dir ?? null,
|
|
589
|
+
cacheDir,
|
|
590
|
+
runtimePresent,
|
|
591
|
+
pullNeeded: !runtimePresent,
|
|
592
|
+
url: target.url,
|
|
593
|
+
};
|
|
594
|
+
emit(JSON.stringify(status, null, 2) + "\n", flags.out);
|
|
595
|
+
} else if (sub === "pull") {
|
|
596
|
+
// Default: the official per-release asset + its `.sha256` sidecar. A
|
|
597
|
+
// user-set CODEINDEX_GRAMMARS_URL overrides both (mirror/custom, no
|
|
598
|
+
// verification). Fetch the expected digest from the sidecar first; a
|
|
599
|
+
// missing sidecar degrades to an unverified pull with a note (never fatal).
|
|
600
|
+
const target = resolveGrammarsPullTarget();
|
|
601
|
+
let expected: string | undefined;
|
|
602
|
+
if (target.sha256Url) {
|
|
603
|
+
try {
|
|
604
|
+
expected = await fetchExpectedSha256(target.sha256Url);
|
|
605
|
+
} catch (e) {
|
|
606
|
+
process.stderr.write(
|
|
607
|
+
`codeindex: could not fetch checksum (${e instanceof Error ? e.message : String(e)}) — proceeding unverified\n`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Idempotent: the marker sibling records the digest of the tarball that
|
|
612
|
+
// populated cacheDir. If the runtime wasm is present AND the marker matches
|
|
613
|
+
// the freshly-fetched digest, the cache is already up to date — skip the
|
|
614
|
+
// ~22 MB download entirely. (Keeps cacheDir itself byte-identical to the
|
|
615
|
+
// tarball; the marker lives next to it, never inside it.)
|
|
616
|
+
const runtime = join(cacheDir, "web-tree-sitter.wasm");
|
|
617
|
+
const markerPath = join(dirname(cacheDir), `${ENGINE_VERSION}.sha256`);
|
|
618
|
+
if (existsSync(runtime) && expected && existsSync(markerPath)) {
|
|
619
|
+
let marker = "";
|
|
620
|
+
try {
|
|
621
|
+
marker = readFileSync(markerPath, "utf8").trim();
|
|
622
|
+
} catch {
|
|
623
|
+
// unreadable marker → fall through and re-pull
|
|
624
|
+
}
|
|
625
|
+
if (marker === expected) {
|
|
626
|
+
process.stderr.write(`codeindex: grammars already present at ${cacheDir} (up to date)\n`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
process.stderr.write(`codeindex: fetching grammars from ${target.url} → ${cacheDir}\n`);
|
|
631
|
+
let bytes: Uint8Array;
|
|
632
|
+
try {
|
|
633
|
+
// Follows redirects (GitHub → CDN) and verifies sha256 when known.
|
|
634
|
+
bytes = await fetchGrammarsTarball(target.url, expected);
|
|
635
|
+
} catch (e) {
|
|
636
|
+
process.stderr.write(`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`);
|
|
637
|
+
process.exitCode = 1;
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// Atomic install: extract into a tmp dir SIBLING to cacheDir (same
|
|
641
|
+
// filesystem → rename is atomic), sanity-check the runtime wasm landed,
|
|
642
|
+
// then swap it into place. A failure at any step discards the tmp dir and
|
|
643
|
+
// leaves any existing cache untouched — a half-populated cache never shows.
|
|
644
|
+
let tmp: string | undefined;
|
|
645
|
+
try {
|
|
646
|
+
mkdirSync(dirname(cacheDir), { recursive: true });
|
|
647
|
+
tmp = mkdtempSync(join(dirname(cacheDir), ".grammars-tmp-"));
|
|
648
|
+
extractGrammarsTarball(bytes, tmp);
|
|
649
|
+
if (!existsSync(join(tmp, "web-tree-sitter.wasm"))) {
|
|
650
|
+
throw new Error("archive is missing web-tree-sitter.wasm");
|
|
651
|
+
}
|
|
652
|
+
if (existsSync(cacheDir)) rmSync(cacheDir, { recursive: true, force: true });
|
|
653
|
+
renameSync(tmp, cacheDir);
|
|
654
|
+
tmp = undefined;
|
|
655
|
+
if (expected) writeFileSync(markerPath, expected + "\n");
|
|
656
|
+
} catch (e) {
|
|
657
|
+
if (tmp) {
|
|
658
|
+
try {
|
|
659
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
660
|
+
} catch {
|
|
661
|
+
// best-effort cleanup
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
process.stderr.write(
|
|
665
|
+
`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`,
|
|
666
|
+
);
|
|
667
|
+
process.exitCode = 1;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
process.stderr.write(`codeindex: grammars extracted → ${cacheDir}\n`);
|
|
671
|
+
} else {
|
|
672
|
+
throw new Error("grammars needs a subcommand: status | pull");
|
|
673
|
+
}
|
|
432
674
|
} else if (cmd === "rules") {
|
|
433
675
|
if (!flags.config) throw new Error("rules needs --config <codeindex.rules.json>");
|
|
434
676
|
const rules = parseRules(JSON.parse(readFileSync(flags.config, "utf8")));
|
|
435
|
-
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
677
|
+
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
436
678
|
const violations = checkRules(graph, rules);
|
|
437
679
|
const errors = violations.filter((v) => v.severity === "error").length;
|
|
438
680
|
emit(JSON.stringify({ errors, warnings: violations.length - errors, violations }, null, 2) + "\n", flags.out);
|
|
@@ -453,26 +695,26 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
453
695
|
for (const k of [...churn.keys()].sort()) sorted[k] = churn.get(k)!;
|
|
454
696
|
emit(JSON.stringify({ ok, churn: sorted }, null, 2) + "\n", flags.out);
|
|
455
697
|
} else if (cmd === "repomap") {
|
|
456
|
-
const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
698
|
+
const { scan, graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
457
699
|
emit(renderRepoMap(scan, graph, { budgetTokens: flags.budgetTokens }), flags.out);
|
|
458
700
|
} else if (cmd === "hotspots") {
|
|
459
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
701
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
460
702
|
const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
|
|
461
703
|
emit(JSON.stringify({ churnOk: ok, hotspots: rankHotspots(scan, churn) }, null, 2) + "\n", flags.out);
|
|
462
704
|
} else if (cmd === "coupling") {
|
|
463
705
|
const { ok, couplings } = changeCoupling(flags.repo, { since: flags.since });
|
|
464
706
|
emit(JSON.stringify({ ok, couplings }, null, 2) + "\n", flags.out);
|
|
465
707
|
} else if (cmd === "deadcode") {
|
|
466
|
-
emit(JSON.stringify(findDeadCode(scanRepo(flags.repo, scanOptions(flags))), null, 2) + "\n", flags.out);
|
|
708
|
+
emit(JSON.stringify(findDeadCode(scanRepo(flags.repo, scanOptions(flags, precomputedWalk))), null, 2) + "\n", flags.out);
|
|
467
709
|
} else if (cmd === "complexity") {
|
|
468
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
710
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
469
711
|
emit(JSON.stringify(symbolComplexity(scan, flags.positional), null, 2) + "\n", flags.out);
|
|
470
712
|
} else if (cmd === "risk") {
|
|
471
|
-
const scan = scanRepo(flags.repo, scanOptions(flags));
|
|
713
|
+
const scan = scanRepo(flags.repo, scanOptions(flags, precomputedWalk));
|
|
472
714
|
const { churn, ok } = gitChurn(flags.repo, { since: flags.since });
|
|
473
715
|
emit(JSON.stringify({ churnOk: ok, risks: riskHotspots(scan, churn) }, null, 2) + "\n", flags.out);
|
|
474
716
|
} else if (cmd === "mermaid") {
|
|
475
|
-
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags));
|
|
717
|
+
const { graph } = buildIndexArtifacts(flags.repo, scanOptions(flags, precomputedWalk));
|
|
476
718
|
emit(renderMermaid(graph, { module: flags.positional }), flags.out);
|
|
477
719
|
} else if (cmd === "grep") {
|
|
478
720
|
if (!flags.positional) throw new Error("grep needs a pattern: cli.mjs grep <pattern> --repo <dir>");
|
package/src/engine.ts
CHANGED
|
@@ -45,8 +45,23 @@ export { extractMarkdown } from "./extract/markdown.js";
|
|
|
45
45
|
export type { MarkdownInfo } from "./extract/markdown.js";
|
|
46
46
|
|
|
47
47
|
// AST tier (optional — a no-op without the grammar wasm sidecar).
|
|
48
|
-
export { ensureGrammars, allGrammarKeys, grammarKeyForExt, grammarReady } from "./ast/loader.js";
|
|
48
|
+
export { ensureGrammars, allGrammarKeys, grammarKeysForExts, grammarKeyForExt, grammarReady } from "./ast/loader.js";
|
|
49
49
|
export { extractAst } from "./ast/extract.js";
|
|
50
|
+
// Grammars resolution + the slim pull/cache tier (v2.14.0): resolve the wasm
|
|
51
|
+
// dir across bundle-adjacent → env → shared cache → none, and fetch the
|
|
52
|
+
// committed wasms into the shared cache when none ship next to the bundle
|
|
53
|
+
// (`codeindex grammars pull|status`). Additive; opt-in; offline-safe.
|
|
54
|
+
export { resolveGrammarsDir, resolveGrammarsTier, sharedGrammarsCacheDir } from "./ast/loader.js";
|
|
55
|
+
export type { GrammarsTier, GrammarsTierName } from "./ast/loader.js";
|
|
56
|
+
export {
|
|
57
|
+
DEFAULT_GRAMMARS_URL,
|
|
58
|
+
resolveGrammarsPullTarget,
|
|
59
|
+
fetchGrammarsTarball,
|
|
60
|
+
fetchExpectedSha256,
|
|
61
|
+
extractTarInto,
|
|
62
|
+
extractGrammarsTarball,
|
|
63
|
+
} from "./ast/grammars-pull.js";
|
|
64
|
+
export type { GrammarsPullTarget } from "./ast/grammars-pull.js";
|
|
50
65
|
|
|
51
66
|
// Resolution + modules + graph tier.
|
|
52
67
|
export { buildResolveContext, resolveImport, resolveDocLink } from "./resolve.js";
|
|
@@ -78,8 +93,9 @@ export { renderGraphJson } from "./render/graph-json.js";
|
|
|
78
93
|
export { renderScip } from "./render/scip.js";
|
|
79
94
|
export type { RenderScipOptions } from "./render/scip.js";
|
|
80
95
|
|
|
81
|
-
// One-call pipeline
|
|
82
|
-
|
|
96
|
+
// One-call pipeline (buildArtifactsFromScan: the scan-onward half, for callers
|
|
97
|
+
// that already hold a RepoScan).
|
|
98
|
+
export { buildIndexArtifacts, buildArtifactsFromScan } from "./pipeline.js";
|
|
83
99
|
export type { BuildIndexOptions, IndexArtifacts } from "./pipeline.js";
|
|
84
100
|
|
|
85
101
|
// Git utilities.
|
|
@@ -161,6 +177,7 @@ export type { RepoMapOptions } from "./repomap.js";
|
|
|
161
177
|
|
|
162
178
|
// MCP server over stdio (also reachable as `engine.mjs mcp`).
|
|
163
179
|
export { runMcpServer } from "./mcp.js";
|
|
180
|
+
export type { McpServerOptions } from "./mcp.js";
|
|
164
181
|
|
|
165
182
|
// General-purpose helpers shared by consumers (deterministic, dependency-free).
|
|
166
183
|
export { sha1, shortHash } from "./hash.js";
|
package/src/hash.ts
CHANGED
|
@@ -2,8 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
// Stable content hash used for the manifest's staleness oracle and for the
|
|
4
4
|
// `ui:gen` region fingerprints. sha1 is plenty for change detection (not
|
|
5
|
-
// security) and keeps the manifest compact.
|
|
6
|
-
|
|
5
|
+
// security) and keeps the manifest compact. Accepts raw bytes too (additive)
|
|
6
|
+
// so binary artifacts (e.g. embeddings.bin) hash without a lossy decode; a
|
|
7
|
+
// string is hashed as its UTF-8 bytes — identical to hashing the bytes
|
|
8
|
+
// writeFileSync would put on disk for that string.
|
|
9
|
+
export function sha1(s: string | Uint8Array): string {
|
|
7
10
|
return createHash("sha1").update(s).digest("hex");
|
|
8
11
|
}
|
|
9
12
|
|