@maxgfr/codeindex 2.11.1 → 2.13.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 +5 -1
- package/docs/MIGRATION.md +39 -1
- package/docs/SEMANTIC.md +249 -0
- package/package.json +3 -2
- package/scripts/engine.d.mts +25 -6
- package/scripts/engine.mjs +179 -76
- package/src/ast/extract.ts +7 -5
- package/src/callers.ts +0 -0
- package/src/embed/model.ts +73 -25
- package/src/engine-cli.ts +31 -21
- package/src/engine.ts +6 -2
- package/src/extract/code.ts +9 -5
- package/src/lang/common.ts +18 -7
- package/src/lang/js-ts.ts +1 -1
- package/src/mcp.ts +111 -15
- package/src/scan.ts +9 -1
- package/src/types.ts +13 -4
- package/src/walk.ts +15 -2
package/src/embed/model.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
@@ -20,12 +21,24 @@ import { join } from "node:path";
|
|
|
20
21
|
// graph.json / symbols.json consumers.
|
|
21
22
|
export const EMBED_VERSION = 1;
|
|
22
23
|
|
|
23
|
-
// The default
|
|
24
|
-
// URL is intentionally NOT hard-coded yet (see resolveEmbedPullUrl): the asset
|
|
25
|
-
// is unpublished, so `pull` fails cleanly asking for CODEINDEX_EMBED_URL rather
|
|
26
|
-
// than fetching a phantom.
|
|
24
|
+
// The default sub-directory a `pull` writes into and a `status` reports.
|
|
27
25
|
export const DEFAULT_EMBED_DIRNAME = "models";
|
|
28
26
|
|
|
27
|
+
// The official static-embedding asset, published as the dedicated GitHub release
|
|
28
|
+
// `embed-model-v1` (tag deliberately outside the `v<semver>` semantic-release
|
|
29
|
+
// namespace, so publishing it triggers no CI). `pull` uses this when
|
|
30
|
+
// CODEINDEX_EMBED_URL is unset. Reproduce the asset byte-for-byte with the pinned
|
|
31
|
+
// toolchain in scripts/embed-asset/.
|
|
32
|
+
export const DEFAULT_EMBED_URL =
|
|
33
|
+
"https://github.com/maxgfr/codeindex/releases/download/embed-model-v1/model.json";
|
|
34
|
+
|
|
35
|
+
// sha256 of the published model.json. `pull` verifies the downloaded bytes
|
|
36
|
+
// against this ONLY when it fetched the built-in default (a custom
|
|
37
|
+
// CODEINDEX_EMBED_URL is the user's own mirror and keeps the un-verified
|
|
38
|
+
// behavior). Regenerate alongside DEFAULT_EMBED_URL when re-cutting the asset:
|
|
39
|
+
// `shasum -a 256 model.json`.
|
|
40
|
+
export const EMBED_ASSET_SHA256 = "163ad053eab4e9a80d421ed4164f32292c83290f02fbbe6fe4b9b1cd6ea18d34";
|
|
41
|
+
|
|
29
42
|
// A loaded static-embedding model. `vocab` maps a wordpiece token to its row
|
|
30
43
|
// index; `weights` is the flat row-major (vocabSize × dim) matrix as IEEE-754
|
|
31
44
|
// doubles (float rows widen to double exactly, so mean-pool stays deterministic).
|
|
@@ -74,20 +87,17 @@ export function hasEmbedModel(repo?: string): boolean {
|
|
|
74
87
|
return resolveEmbedModelDir(repo) !== undefined;
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
85
|
-
|
|
86
|
-
const { modelId, dim, vocab, weights } = raw;
|
|
87
|
-
if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
|
|
88
|
-
if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${path}`);
|
|
90
|
+
// Validate a parsed model.json body and construct the loaded model. Throws on
|
|
91
|
+
// a malformed shape (missing modelId, bad dim, vocab/weights mismatch, ragged
|
|
92
|
+
// rows) with a message citing `source` (a file path, a URL, …) so a corrupt
|
|
93
|
+
// asset fails loudly wherever it enters — at load, or at `embed pull` BEFORE
|
|
94
|
+
// the shape-invalid file is ever written to disk.
|
|
95
|
+
export function parseEmbedModel(raw: unknown, source: string): StaticEmbedModel {
|
|
96
|
+
const { modelId, dim, vocab, weights, unk: rawUnk } = (raw ?? {}) as ModelFile;
|
|
97
|
+
if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${source}`);
|
|
98
|
+
if (!Number.isInteger(dim) || dim <= 0) throw new Error(`embed model: bad dim ${dim} in ${source}`);
|
|
89
99
|
if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
|
|
90
|
-
throw new Error(`embed model: vocab/weights length mismatch in ${
|
|
100
|
+
throw new Error(`embed model: vocab/weights length mismatch in ${source}`);
|
|
91
101
|
}
|
|
92
102
|
const vocabSize = vocab.length;
|
|
93
103
|
const flat = new Float64Array(vocabSize * dim);
|
|
@@ -102,16 +112,54 @@ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
|
|
|
102
112
|
}
|
|
103
113
|
for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
|
|
104
114
|
}
|
|
105
|
-
const unk = typeof
|
|
115
|
+
const unk = typeof rawUnk === "string" ? rawUnk : "[UNK]";
|
|
106
116
|
const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
|
|
107
117
|
return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
|
|
108
118
|
}
|
|
109
119
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// at
|
|
113
|
-
// the
|
|
114
|
-
export function
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
// Load and validate a static model from a directory containing model.json.
|
|
121
|
+
// Throws on a malformed file (bad dim, ragged weights) so a corrupt asset fails
|
|
122
|
+
// loudly at load rather than silently misranking. Returns undefined only when
|
|
123
|
+
// the directory has no model.json (the not-present case).
|
|
124
|
+
export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
|
|
125
|
+
if (!dir) return undefined;
|
|
126
|
+
const path = join(dir, "model.json");
|
|
127
|
+
if (!existsSync(path)) return undefined;
|
|
128
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
129
|
+
return parseEmbedModel(raw, path);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// What `embed pull` fetches, and whether to verify it. CODEINDEX_EMBED_URL wins
|
|
133
|
+
// outright (the user's explicit override / private mirror) and carries NO
|
|
134
|
+
// sha256, so a custom asset keeps the current un-verified behavior. With no env,
|
|
135
|
+
// we fall back to the built-in official asset AND its pinned sha256, so `pull`
|
|
136
|
+
// can prove integrity of the default download.
|
|
137
|
+
export interface EmbedPullTarget {
|
|
138
|
+
url: string;
|
|
139
|
+
sha256?: string; // present only for the built-in default → verify
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveEmbedPullUrl(): EmbedPullTarget {
|
|
143
|
+
const env = process.env.CODEINDEX_EMBED_URL;
|
|
144
|
+
if (env && env.trim()) return { url: env.trim() };
|
|
145
|
+
return { url: DEFAULT_EMBED_URL, sha256: EMBED_ASSET_SHA256 };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Fetch a model.json body over HTTP, following redirects (GitHub release assets
|
|
149
|
+
// 302 to a CDN — native fetch follows by default). When `expectedSha256` is
|
|
150
|
+
// given, the downloaded bytes are hashed and MUST match or this throws with a
|
|
151
|
+
// clear message and returns nothing — so a corrupt/tampered default asset never
|
|
152
|
+
// gets written. A non-2xx response also throws. Verification and I/O are split
|
|
153
|
+
// from the CLI so both the success and the sha-mismatch paths are unit-testable.
|
|
154
|
+
export async function fetchEmbedModel(url: string, expectedSha256?: string): Promise<string> {
|
|
155
|
+
const res = await fetch(url);
|
|
156
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
157
|
+
const body = await res.text();
|
|
158
|
+
if (expectedSha256) {
|
|
159
|
+
const got = createHash("sha256").update(body).digest("hex");
|
|
160
|
+
if (got !== expectedSha256) {
|
|
161
|
+
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${got}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return body;
|
|
117
165
|
}
|
package/src/engine-cli.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { symbolComplexity, riskHotspots } from "./complexity.js";
|
|
|
19
19
|
import { renderMermaid } from "./viz.js";
|
|
20
20
|
import { searchIndex } from "./bm25.js";
|
|
21
21
|
import { checkRules, parseRules } from "./rules.js";
|
|
22
|
-
import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, resolveEmbedPullUrl } from "./embed/model.js";
|
|
22
|
+
import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, parseEmbedModel, resolveEmbedPullUrl, fetchEmbedModel } from "./embed/model.js";
|
|
23
23
|
import { buildEmbeddingIndex, serializeEmbeddings } from "./embed/index.js";
|
|
24
24
|
import { searchSemantic } from "./embed/search.js";
|
|
25
25
|
import {
|
|
@@ -55,8 +55,9 @@ Commands:
|
|
|
55
55
|
embed status Effective mode (none/static/endpoint), model +
|
|
56
56
|
EMBED_VERSION, and endpoint reachability (JSON)
|
|
57
57
|
embed build Write embeddings.bin into --out <dir> (static tier)
|
|
58
|
-
embed pull Fetch the model asset into CODEINDEX_EMBED_DIR
|
|
59
|
-
<repo>/.codeindex/models/)
|
|
58
|
+
embed pull Fetch the official model asset into CODEINDEX_EMBED_DIR
|
|
59
|
+
(or <repo>/.codeindex/models/); sha256-verified. Override
|
|
60
|
+
the source with CODEINDEX_EMBED_URL
|
|
60
61
|
embed serve Print (or --run) the docker command that starts the
|
|
61
62
|
containerized embedding server (rich tier)
|
|
62
63
|
rules Architecture rules (forbidden edges, cycles, orphans) validated
|
|
@@ -79,8 +80,11 @@ Flags:
|
|
|
79
80
|
--exclude <glob> Exclude matching paths (repeatable)
|
|
80
81
|
--scope <dir> Restrict to one directory (sugar for --include '<dir>/**')
|
|
81
82
|
--no-gitignore Do not honor .gitignore files (default: honored)
|
|
83
|
+
--ignore-dir <name> Directory names to skip (repeatable) — REPLACES the
|
|
84
|
+
default ignored-directory set, never merges with it
|
|
82
85
|
--max-files <n> Cap walked files (default 20000)
|
|
83
86
|
--max-bytes <n> Skip files above this size (default 1 MiB)
|
|
87
|
+
--max-calls <n> Per-file call-site cap for extraction (default 512)
|
|
84
88
|
--no-ast Skip tree-sitter grammars even when present (regex tier)
|
|
85
89
|
--config <file> Rules config for \`rules\` (JSON: [{name, from, to, …}])
|
|
86
90
|
--limit <n> Max results for \`search\` (default 20)
|
|
@@ -102,8 +106,10 @@ interface CliFlags {
|
|
|
102
106
|
exclude: string[];
|
|
103
107
|
scope?: string;
|
|
104
108
|
gitignore: boolean;
|
|
109
|
+
ignoreDirs: string[];
|
|
105
110
|
maxFiles?: number;
|
|
106
111
|
maxBytes?: number;
|
|
112
|
+
maxCalls?: number;
|
|
107
113
|
noAst: boolean;
|
|
108
114
|
since?: string;
|
|
109
115
|
ignoreCase?: boolean;
|
|
@@ -120,7 +126,7 @@ interface CliFlags {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
function parseFlags(args: string[]): CliFlags {
|
|
123
|
-
const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, noAst: false, fuzzy: true, semantic: false };
|
|
129
|
+
const flags: CliFlags = { repo: process.cwd(), include: [], exclude: [], gitignore: true, ignoreDirs: [], noAst: false, fuzzy: true, semantic: false };
|
|
124
130
|
for (let i = 0; i < args.length; i++) {
|
|
125
131
|
const a = args[i]!;
|
|
126
132
|
const next = (): string => {
|
|
@@ -143,8 +149,10 @@ function parseFlags(args: string[]): CliFlags {
|
|
|
143
149
|
else if (a === "--exclude") flags.exclude.push(next());
|
|
144
150
|
else if (a === "--scope") flags.scope = next();
|
|
145
151
|
else if (a === "--no-gitignore") flags.gitignore = false;
|
|
152
|
+
else if (a === "--ignore-dir") flags.ignoreDirs.push(next());
|
|
146
153
|
else if (a === "--max-files") flags.maxFiles = num();
|
|
147
154
|
else if (a === "--max-bytes") flags.maxBytes = num();
|
|
155
|
+
else if (a === "--max-calls") flags.maxCalls = num();
|
|
148
156
|
else if (a === "--ignore-case") flags.ignoreCase = true;
|
|
149
157
|
else if (a === "--max-hits") flags.maxHits = num();
|
|
150
158
|
else if (a === "--budget-tokens") flags.budgetTokens = num();
|
|
@@ -173,8 +181,10 @@ function scanOptions(flags: CliFlags): BuildIndexOptions {
|
|
|
173
181
|
exclude: flags.exclude.length ? flags.exclude : undefined,
|
|
174
182
|
scope: flags.scope,
|
|
175
183
|
gitignore: flags.gitignore,
|
|
184
|
+
ignoreDirs: flags.ignoreDirs.length ? flags.ignoreDirs : undefined,
|
|
176
185
|
maxFiles: flags.maxFiles,
|
|
177
186
|
maxBytes: flags.maxBytes,
|
|
187
|
+
maxCallsPerFile: flags.maxCalls,
|
|
178
188
|
};
|
|
179
189
|
}
|
|
180
190
|
|
|
@@ -387,30 +397,30 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
387
397
|
writeFileSync(join(flags.out, "embeddings.bin"), serializeEmbeddings(index));
|
|
388
398
|
process.stderr.write(`codeindex: ${index.records.length} embedding records → ${flags.out}/embeddings.bin (model ${model.modelId})\n`);
|
|
389
399
|
} else if (sub === "pull") {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
process.stderr.write(
|
|
394
|
-
"codeindex: no model URL configured. The official static-embedding asset is not published yet.\n" +
|
|
395
|
-
"Set CODEINDEX_EMBED_URL to a model.json URL (optionally CODEINDEX_EMBED_DIR as the destination), then re-run `codeindex embed pull`.\n",
|
|
396
|
-
);
|
|
397
|
-
process.exitCode = 1;
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
+
// Default: the official published asset + its pinned sha256. A user-set
|
|
401
|
+
// CODEINDEX_EMBED_URL overrides both (mirror/custom model, no verification).
|
|
402
|
+
const { url, sha256 } = resolveEmbedPullUrl();
|
|
400
403
|
const destDir = process.env.CODEINDEX_EMBED_DIR ?? join(flags.repo, ".codeindex", "models");
|
|
401
404
|
mkdirSync(destDir, { recursive: true });
|
|
402
405
|
process.stderr.write(`codeindex: fetching model from ${url} → ${join(destDir, "model.json")}\n`);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
+
let body: string;
|
|
407
|
+
try {
|
|
408
|
+
// Follows redirects (GitHub → CDN) and verifies sha256 for the default asset.
|
|
409
|
+
body = await fetchEmbedModel(url, sha256);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
process.stderr.write(`codeindex: pull failed — ${e instanceof Error ? e.message : String(e)} (nothing written)\n`);
|
|
406
412
|
process.exitCode = 1;
|
|
407
413
|
return;
|
|
408
414
|
}
|
|
409
|
-
const body = await res.text();
|
|
410
415
|
try {
|
|
411
|
-
JSON
|
|
412
|
-
|
|
413
|
-
|
|
416
|
+
// Shape-validate BEFORE writing: a JSON-valid but shape-invalid asset
|
|
417
|
+
// would otherwise land on disk and turn every later semantic search
|
|
418
|
+
// into a hard loadEmbedModel error instead of the documented degrade.
|
|
419
|
+
parseEmbedModel(JSON.parse(body), url);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
process.stderr.write(
|
|
422
|
+
`codeindex: pull failed — response is not a valid model.json (${e instanceof Error ? e.message : String(e)}) (nothing written)\n`,
|
|
423
|
+
);
|
|
414
424
|
process.exitCode = 1;
|
|
415
425
|
return;
|
|
416
426
|
}
|
package/src/engine.ts
CHANGED
|
@@ -55,7 +55,7 @@ export { buildModules, isTestFile, tierForPath } from "./modules.js";
|
|
|
55
55
|
export type { ModuleInfo } from "./modules.js";
|
|
56
56
|
export { buildGraph, uniqueSymbolDefs } from "./graph.js";
|
|
57
57
|
export { resolveCallEdges } from "./calls.js";
|
|
58
|
-
export { buildCallerIndex, enclosingSymbol, computeImportPairs } from "./callers.js";
|
|
58
|
+
export { buildCallerIndex, buildRawCallerIndex, enclosingSymbol, computeImportPairs } from "./callers.js";
|
|
59
59
|
export { symbolsOverview, findSymbol, findReferences } from "./query.js";
|
|
60
60
|
export { resolveUniqueSymbol, replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit.js";
|
|
61
61
|
export type { EditResult } from "./edit.js";
|
|
@@ -114,7 +114,7 @@ export {
|
|
|
114
114
|
loadEmbedModel,
|
|
115
115
|
resolveEmbedPullUrl,
|
|
116
116
|
} from "./embed/model.js";
|
|
117
|
-
export type { StaticEmbedModel } from "./embed/model.js";
|
|
117
|
+
export type { StaticEmbedModel, EmbedPullTarget } from "./embed/model.js";
|
|
118
118
|
export { encode, quantize, tokenize, wordpiece, basicTokenize, roundHalfToEven, intDot } from "./embed/encode.js";
|
|
119
119
|
export { buildEmbeddingIndex, serializeEmbeddings, deserializeEmbeddings, embeddingUnits } from "./embed/index.js";
|
|
120
120
|
export type { EmbeddingIndex, EmbeddingRecord, EmbeddingUnit } from "./embed/index.js";
|
|
@@ -143,6 +143,10 @@ export type { ArchRule, ForbiddenEdgeRule, BuiltinRule, RuleSeverity, RuleViolat
|
|
|
143
143
|
// Caller-index recall mode (issue #7) — options for buildCallerIndex above.
|
|
144
144
|
export type { CallerIndexOptions } from "./callers.js";
|
|
145
145
|
|
|
146
|
+
// Raw-recall caller index (issue #8) — ungated, def-resolution-free companion
|
|
147
|
+
// to buildCallerIndex above; see its JSDoc in callers.ts for the contract.
|
|
148
|
+
export type { RawCallerIndex, RawCallerSite } from "./callers.js";
|
|
149
|
+
|
|
146
150
|
// Behavioral analytics (git-history mining) + the token-budgeted repo map.
|
|
147
151
|
export { changeCoupling, rankHotspots } from "./coupling.js";
|
|
148
152
|
export type { ChangeCoupling, CouplingOptions, Hotspot } from "./coupling.js";
|
package/src/extract/code.ts
CHANGED
|
@@ -302,12 +302,13 @@ const DEF_INTRODUCERS = /(?:\bfunction|\bdef|\bfunc|\bfun|\bfn|\bclass|\bsub|\bm
|
|
|
302
302
|
export function collectCallsRegex(
|
|
303
303
|
content: string,
|
|
304
304
|
symbols: Pick<CodeSymbol, "name" | "line">[] = [],
|
|
305
|
+
maxCalls: number = 512,
|
|
305
306
|
): { name: string; line: number; receiver?: string }[] {
|
|
306
307
|
const out = new Map<string, { name: string; line: number; receiver?: string }>();
|
|
307
308
|
const ownDefLines = new Set(symbols.map((s) => `${s.name} ${s.line}`));
|
|
308
309
|
const lines = content.split("\n");
|
|
309
310
|
const CALL_RE = /(?:\bnew\s+)?(?:([A-Za-z_$][\w$]*)\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
|
|
310
|
-
for (let i = 0; i < lines.length && out.size <
|
|
311
|
+
for (let i = 0; i < lines.length && out.size < maxCalls; i++) {
|
|
311
312
|
const line = lines[i]!;
|
|
312
313
|
// Cheap comment guard: a line-leading comment marker means no calls here
|
|
313
314
|
// (block-comment interiors and strings stay best-effort, like the symbol
|
|
@@ -332,7 +333,7 @@ export function collectCallsRegex(
|
|
|
332
333
|
CALL_RE.lastIndex = 0;
|
|
333
334
|
let m: RegExpExecArray | null;
|
|
334
335
|
const fallbackExcluded = new Set<string>();
|
|
335
|
-
while ((m = CALL_RE.exec(line)) !== null && out.size <
|
|
336
|
+
while ((m = CALL_RE.exec(line)) !== null && out.size < maxCalls) {
|
|
336
337
|
const receiver = m[1];
|
|
337
338
|
const name = m[2]!;
|
|
338
339
|
if (name.length < 2 || CALL_KEYWORDS.has(name)) continue;
|
|
@@ -350,13 +351,16 @@ export function collectCallsRegex(
|
|
|
350
351
|
return [...out.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.line - b.line));
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
|
|
354
|
+
// `opts.maxCallsPerFile` overrides the per-file call-site cap (default 512) on
|
|
355
|
+
// BOTH extraction tiers — AST and regex — so recall-oriented consumers can raise
|
|
356
|
+
// it. Dedup/sort semantics are unchanged; absent, output is byte-identical.
|
|
357
|
+
export function extractCode(rel: string, ext: string, content: string, opts: { maxCallsPerFile?: number } = {}): CodeInfo {
|
|
354
358
|
// Symbols come from tree-sitter when a grammar is loaded for this extension
|
|
355
359
|
// (AST-exact: real nesting, precise kinds, structural export), else the regex
|
|
356
360
|
// extractors. Imports/pkg stay on the battle-tested regex path here — their
|
|
357
361
|
// resolution is covered by resolve tests and the e2e ratchet; the new-language
|
|
358
362
|
// AST importers land with their resolvers.
|
|
359
|
-
const ast = extractAst(rel, ext, content);
|
|
363
|
+
const ast = extractAst(rel, ext, content, { maxCalls: opts.maxCallsPerFile });
|
|
360
364
|
const symbols = (ast ? ast.symbols : extractSymbols(rel, ext, content)).slice(0, 400);
|
|
361
365
|
// Add barrel re-exports the local def didn't already cover.
|
|
362
366
|
const known = new Set(symbols.map((s) => s.name));
|
|
@@ -378,7 +382,7 @@ export function extractCode(rel: string, ext: string, content: string): CodeInfo
|
|
|
378
382
|
// collector otherwise, so caller indexes exist without the wasm sidecar.
|
|
379
383
|
// `symbols` (this file's own regex-extracted defs) lets the collector
|
|
380
384
|
// exclude a definition's own name+line from its call candidates.
|
|
381
|
-
calls: ast ? ast.calls : collectCallsRegex(content, symbols),
|
|
385
|
+
calls: ast ? ast.calls : collectCallsRegex(content, symbols, opts.maxCallsPerFile),
|
|
382
386
|
importedNames: ast?.importedNames,
|
|
383
387
|
};
|
|
384
388
|
}
|
package/src/lang/common.ts
CHANGED
|
@@ -75,11 +75,14 @@ const REEXPORT_EXTS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".m
|
|
|
75
75
|
//
|
|
76
76
|
// An ALIAS with no `from` clause (`export { b as c }`) renames an in-file
|
|
77
77
|
// declaration — `localSymbols` (already extracted by the AST or regex tier)
|
|
78
|
-
// lets us resolve `b` and mirror ITS kind
|
|
79
|
-
//
|
|
78
|
+
// lets us resolve `b` and mirror ITS kind, declaration line, and endLine (AST
|
|
79
|
+
// tier only) onto `c` (e.g. "function" at b's own line), so the alias reads
|
|
80
|
+
// as the real symbol it is — citeable at its actual declaration — rather than
|
|
81
|
+
// the generic "reexport" pinned to the export statement's line.
|
|
80
82
|
// A true cross-module re-export (`export { b as c } from "./mod"`) has no
|
|
81
83
|
// local `b` to resolve — and an alias the local pass genuinely can't see
|
|
82
|
-
// (destructured/ambient/etc.) falls back the same way — both keep "reexport"
|
|
84
|
+
// (destructured/ambient/etc.) falls back the same way — both keep "reexport"
|
|
85
|
+
// and cite the export statement's own line, the only line they have.
|
|
83
86
|
//
|
|
84
87
|
// Shared by extractCode (extract/code.ts) AND the standalone extractSymbols
|
|
85
88
|
// (lang/registry.ts) — ultradoc and other direct extractSymbols consumers hit
|
|
@@ -92,8 +95,11 @@ export function extractReexports(rel: string, content: string, localSymbols: Cod
|
|
|
92
95
|
const out: CodeSymbol[] = [];
|
|
93
96
|
const seen = new Set<string>();
|
|
94
97
|
const lineAt = (idx: number): number => content.slice(0, idx).split(/\r?\n/).length;
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
// Keyed on the whole CodeSymbol (not just kind) so the alias branch below
|
|
99
|
+
// can also cite the resolved declaration's own line/endLine, not just mirror
|
|
100
|
+
// its kind.
|
|
101
|
+
const localDeclOf = new Map<string, CodeSymbol>();
|
|
102
|
+
for (const s of localSymbols) if (!localDeclOf.has(s.name)) localDeclOf.set(s.name, s);
|
|
97
103
|
|
|
98
104
|
const named = /export\s*\{([\s\S]*?)\}\s*(?:from\s*['"]([^'"]+)['"])?\s*;?/g;
|
|
99
105
|
let m: RegExpExecArray | null;
|
|
@@ -106,9 +112,14 @@ export function extractReexports(rel: string, content: string, localSymbols: Cod
|
|
|
106
112
|
const name = as ? as[2]! : p;
|
|
107
113
|
if (!/^[A-Za-z_$][\w$]*$/.test(name) || name === "default" || seen.has(name)) continue;
|
|
108
114
|
seen.add(name);
|
|
109
|
-
|
|
115
|
+
// A resolved decl means this is a same-file alias: cite ITS line (and
|
|
116
|
+
// endLine, when the AST tier populated one) rather than the export
|
|
117
|
+
// statement's — an unresolved alias or a `from`-clause re-export has no
|
|
118
|
+
// local declaration to point at, so it keeps lineAt(m.index) below.
|
|
119
|
+
const decl = !from ? localDeclOf.get(orig) : undefined;
|
|
110
120
|
out.push({
|
|
111
|
-
name, kind:
|
|
121
|
+
name, kind: decl?.kind ?? "reexport", file: rel, line: decl ? decl.line : lineAt(m.index),
|
|
122
|
+
...(decl?.endLine !== undefined ? { endLine: decl.endLine } : {}),
|
|
112
123
|
signature: from ? `export { ${name} } from "${from}"` : `export { ${name} }`,
|
|
113
124
|
exported: true, lang,
|
|
114
125
|
});
|
package/src/lang/js-ts.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { scan, type Rule } from "./common.js";
|
|
|
7
7
|
const RULES: Rule[] = [
|
|
8
8
|
{ re: /^\s*export\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
|
|
9
9
|
{ re: /^\s*export\s+default\s+(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: true },
|
|
10
|
-
{ re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
|
|
10
|
+
{ re: /^\s*export\s+default\s+(?:abstract\s+)?class\s+(?!extends\b)(?<name>[\w$]+)/, kind: "class", exported: true },
|
|
11
11
|
{ re: /^\s*(?:async\s+)?function\s+(?<name>[\w$]+)/, kind: "function", exported: false },
|
|
12
12
|
{ re: /^\s*export\s+(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: true },
|
|
13
13
|
{ re: /^\s*(?:abstract\s+)?class\s+(?<name>[\w$]+)/, kind: "class", exported: false },
|
package/src/mcp.ts
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// `repo` path and returns JSON text content.
|
|
6
6
|
//
|
|
7
7
|
// Register in an MCP client as: node scripts/engine.mjs mcp
|
|
8
|
+
import { statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
8
10
|
import { createInterface } from "node:readline";
|
|
9
11
|
import { ENGINE_VERSION } from "./types.js";
|
|
10
12
|
import { ensureGrammars, allGrammarKeys } from "./ast/loader.js";
|
|
11
13
|
import { buildIndexArtifacts } from "./pipeline.js";
|
|
12
14
|
import { renderGraphJson } from "./render/graph-json.js";
|
|
13
|
-
import { scanRepo } from "./scan.js";
|
|
15
|
+
import { scanRepo, type RepoScan } from "./scan.js";
|
|
14
16
|
import { buildCallerIndex } from "./callers.js";
|
|
15
17
|
import { detectWorkspaces } from "./workspaces.js";
|
|
16
18
|
import { gitChurn } from "./git.js";
|
|
@@ -25,10 +27,11 @@ import { replaceSymbolBody, insertAfterSymbol, insertBeforeSymbol } from "./edit
|
|
|
25
27
|
import { writeMemory, readMemory, deleteMemory, listMemories } from "./memory.js";
|
|
26
28
|
import { searchIndex } from "./bm25.js";
|
|
27
29
|
import { checkRules, parseRules } from "./rules.js";
|
|
28
|
-
import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel } from "./embed/model.js";
|
|
29
|
-
import { buildEmbeddingIndex } from "./embed/index.js";
|
|
30
|
+
import { EMBED_VERSION, resolveEmbedModelDir, loadEmbedModel, type StaticEmbedModel } from "./embed/model.js";
|
|
31
|
+
import { buildEmbeddingIndex, type EmbeddingIndex } from "./embed/index.js";
|
|
30
32
|
import { searchSemantic } from "./embed/search.js";
|
|
31
33
|
import { resolveEmbedEndpoint, buildEndpointIndex, encodeQueryViaEndpoint, probeEndpoint } from "./embed/endpoint.js";
|
|
34
|
+
import { sha1 } from "./hash.js";
|
|
32
35
|
|
|
33
36
|
interface RpcRequest {
|
|
34
37
|
jsonrpc: "2.0";
|
|
@@ -270,7 +273,7 @@ const TOOLS = [
|
|
|
270
273
|
{
|
|
271
274
|
name: "search",
|
|
272
275
|
description:
|
|
273
|
-
'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse
|
|
276
|
+
'Natural-language-ish lexical search: BM25 ranking (k1=1.2, b=0.75) over symbol names (camelCase/snake_case subtokens), file path segments, markdown headings and summary lines. NOT embeddings by default — deterministic, diacritic-folded, zero API keys. Answers "where is auth handled?"-style queries with ranked files, matched terms and top symbols. Query terms with zero document frequency get a deterministic trigram-fuzzy fallback (typo-tolerant) unless `fuzzy: false`. Set `semantic: true` to RRF-fuse an embedding tier (HTTP endpoint, else a local static model) with lexical — the response then wraps the ranked list as `{ results, tier, degradedReason? }`, `tier` being "endpoint"/"static" when fusion happened or "lexical" (with `degradedReason`) when it did not (see embed_status). Without `semantic`, the response is the bare ranked array, unchanged.',
|
|
274
277
|
inputSchema: {
|
|
275
278
|
type: "object",
|
|
276
279
|
properties: {
|
|
@@ -286,7 +289,7 @@ const TOOLS = [
|
|
|
286
289
|
semantic: {
|
|
287
290
|
type: "boolean",
|
|
288
291
|
description:
|
|
289
|
-
|
|
292
|
+
'RRF-fuse an embedding tier with lexical (default false). Precedence: the HTTP endpoint (CODEINDEX_EMBED_ENDPOINT) if set, else a local static model. The response reports the effective tier as a top-level `tier` field ("endpoint"/"static" on success, "lexical" plus `degradedReason` when neither is available/reachable) instead of degrading silently — see embed_status.',
|
|
290
293
|
},
|
|
291
294
|
},
|
|
292
295
|
required: ["repo", "query"],
|
|
@@ -320,6 +323,75 @@ function str(v: unknown): string | undefined {
|
|
|
320
323
|
function strArray(v: unknown): string[] | undefined {
|
|
321
324
|
return Array.isArray(v) && v.every((x) => typeof x === "string") && v.length ? (v as string[]) : undefined;
|
|
322
325
|
}
|
|
326
|
+
function errMessage(e: unknown): string {
|
|
327
|
+
return e instanceof Error ? e.message : String(e);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- embedding index memoization --------------------------------------------
|
|
331
|
+
// The MCP server process is long-lived, but every `search` call used to redo
|
|
332
|
+
// the FULL corpus embedding build — N `buildEndpointIndex` HTTP round-trips,
|
|
333
|
+
// or a full `buildEmbeddingIndex` re-encode pass — even when nothing in the
|
|
334
|
+
// repo changed between requests. Memoize the last build behind a fingerprint
|
|
335
|
+
// of the scan contents plus the tier's identity, so an unchanged repo reuses
|
|
336
|
+
// the cached index and any file add/edit/remove (or a switch of endpoint/model)
|
|
337
|
+
// still rebuilds. RepoScan carries no fingerprint of its own (checked
|
|
338
|
+
// scan.ts/types.ts) — every FileRecord already carries a content hash, so
|
|
339
|
+
// hashing the (rel, hash) pairs is the staleness oracle scan.ts itself uses.
|
|
340
|
+
export function scanFingerprint(scan: RepoScan): string {
|
|
341
|
+
return sha1(scan.files.map((f) => `${f.rel}:${f.hash}`).join("\n"));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface EmbeddingIndexCacheKey {
|
|
345
|
+
mode: "endpoint" | "static";
|
|
346
|
+
// Distinguishes cache entries across configs sharing the same scan: the
|
|
347
|
+
// endpoint URL, or the model dir + modelId for the static tier.
|
|
348
|
+
identity: string;
|
|
349
|
+
scan: RepoScan;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// A SINGLE entry — never an unbounded map — holding the most recent build.
|
|
353
|
+
let embeddingIndexCache: { key: string; index: EmbeddingIndex } | undefined;
|
|
354
|
+
|
|
355
|
+
// Reuse the cached index when (mode, identity, scanFingerprint) matches the
|
|
356
|
+
// last build; otherwise call `build` and cache its result. A failed build is
|
|
357
|
+
// NEVER cached (matches today's per-call error behavior: the next request
|
|
358
|
+
// retries from scratch, and a still-valid previous entry — under a different
|
|
359
|
+
// key — is left untouched).
|
|
360
|
+
export async function memoizedEmbeddingIndex(
|
|
361
|
+
key: EmbeddingIndexCacheKey,
|
|
362
|
+
build: () => Promise<EmbeddingIndex> | EmbeddingIndex,
|
|
363
|
+
): Promise<EmbeddingIndex> {
|
|
364
|
+
const cacheKey = `${key.mode}:${key.identity}:${scanFingerprint(key.scan)}`;
|
|
365
|
+
if (embeddingIndexCache && embeddingIndexCache.key === cacheKey) return embeddingIndexCache.index;
|
|
366
|
+
const index = await build();
|
|
367
|
+
embeddingIndexCache = { key: cacheKey, index };
|
|
368
|
+
return index;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A SINGLE entry — never an unbounded map — holding the most recent parse.
|
|
372
|
+
let embedModelCache: { key: string; model: StaticEmbedModel } | undefined;
|
|
373
|
+
|
|
374
|
+
// model.json is 10-30 MB with the real asset; reading + JSON.parsing it on
|
|
375
|
+
// EVERY request dominates static-tier latency, so the parsed model is memoized
|
|
376
|
+
// across requests. One statSync per request keys the cache on
|
|
377
|
+
// (dir, mtimeMs, size) so an in-place re-pull invalidates on the next call.
|
|
378
|
+
// Same discipline as memoizedEmbeddingIndex: a failed load is NEVER cached —
|
|
379
|
+
// the throw propagates and the cache is left as it was, so the next request
|
|
380
|
+
// retries from scratch. A missing model.json returns undefined (the
|
|
381
|
+
// not-present case, exactly like loadEmbedModel).
|
|
382
|
+
export function memoizedEmbedModel(modelDir: string): StaticEmbedModel | undefined {
|
|
383
|
+
let stat;
|
|
384
|
+
try {
|
|
385
|
+
stat = statSync(join(modelDir, "model.json"));
|
|
386
|
+
} catch {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const key = `${modelDir}:${stat.mtimeMs}:${stat.size}`;
|
|
390
|
+
if (embedModelCache && embedModelCache.key === key) return embedModelCache.model;
|
|
391
|
+
const model = loadEmbedModel(modelDir);
|
|
392
|
+
if (model) embedModelCache = { key, model };
|
|
393
|
+
return model;
|
|
394
|
+
}
|
|
323
395
|
|
|
324
396
|
async function callTool(name: string, args: Record<string, unknown>): Promise<string> {
|
|
325
397
|
const repo = str(args.repo);
|
|
@@ -459,31 +531,55 @@ async function callTool(name: string, args: Record<string, unknown>): Promise<st
|
|
|
459
531
|
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
460
532
|
const fuzzy = typeof args.fuzzy === "boolean" ? args.fuzzy : undefined;
|
|
461
533
|
if (args.semantic === true) {
|
|
534
|
+
// semantic:true changes the response SHAPE (wraps the ranked list with a
|
|
535
|
+
// `tier`/`degradedReason?`) so a caller can tell "fusion happened" apart
|
|
536
|
+
// from "degraded to lexical" — see the `search` tool description. This
|
|
537
|
+
// branch is the ONLY place that shape appears; plain lexical search below
|
|
538
|
+
// stays the bare array, byte-compat for existing consumers.
|
|
462
539
|
const endpoint = resolveEmbedEndpoint();
|
|
463
540
|
if (endpoint) {
|
|
464
541
|
// Rich tier — endpoint takes PRECEDENCE over a local static model. An
|
|
465
|
-
// unreachable/malformed endpoint degrades to lexical
|
|
542
|
+
// unreachable/malformed endpoint degrades to lexical, now with a reason.
|
|
543
|
+
// The corpus index is memoized per (endpoint, scan state) — the query
|
|
544
|
+
// itself is always re-encoded fresh (it differs per call).
|
|
466
545
|
try {
|
|
467
|
-
const index = await buildEndpointIndex(scan);
|
|
546
|
+
const index = await memoizedEmbeddingIndex({ mode: "endpoint", identity: endpoint, scan }, () => buildEndpointIndex(scan));
|
|
468
547
|
const queryVec = await encodeQueryViaEndpoint(query);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
548
|
+
const results = searchSemantic(scan, query, index, { queryVec, limit, fuzzy });
|
|
549
|
+
return JSON.stringify({ results, tier: "endpoint" }, null, 2);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
const results = searchIndex(scan, query, { limit, fuzzy });
|
|
552
|
+
return JSON.stringify(
|
|
553
|
+
{ results, tier: "lexical", degradedReason: `embedding endpoint failed: ${errMessage(e)}` },
|
|
554
|
+
null,
|
|
555
|
+
2,
|
|
556
|
+
);
|
|
472
557
|
}
|
|
473
558
|
}
|
|
474
559
|
const modelDir = resolveEmbedModelDir(repo);
|
|
475
|
-
const model = modelDir ?
|
|
560
|
+
const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
|
|
476
561
|
if (model) {
|
|
477
|
-
const index =
|
|
478
|
-
|
|
562
|
+
const index = await memoizedEmbeddingIndex(
|
|
563
|
+
{ mode: "static", identity: `${modelDir}#${model.modelId}`, scan },
|
|
564
|
+
() => buildEmbeddingIndex(scan, model),
|
|
565
|
+
);
|
|
566
|
+
const results = searchSemantic(scan, query, index, { model, limit, fuzzy });
|
|
567
|
+
return JSON.stringify({ results, tier: "static" }, null, 2);
|
|
479
568
|
}
|
|
480
|
-
//
|
|
569
|
+
// Opt-in tier not activated (no endpoint, no model asset) — degrade to
|
|
570
|
+
// lexical with a reason instead of failing silently.
|
|
571
|
+
const results = searchIndex(scan, query, { limit, fuzzy });
|
|
572
|
+
return JSON.stringify(
|
|
573
|
+
{ results, tier: "lexical", degradedReason: "no embedding endpoint or static model configured — see embed_status" },
|
|
574
|
+
null,
|
|
575
|
+
2,
|
|
576
|
+
);
|
|
481
577
|
}
|
|
482
578
|
return JSON.stringify(searchIndex(scan, query, { limit, fuzzy }), null, 2);
|
|
483
579
|
}
|
|
484
580
|
if (name === "embed_status") {
|
|
485
581
|
const modelDir = resolveEmbedModelDir(repo);
|
|
486
|
-
const model = modelDir ?
|
|
582
|
+
const model = modelDir ? memoizedEmbedModel(modelDir) : undefined;
|
|
487
583
|
const endpoint = resolveEmbedEndpoint();
|
|
488
584
|
const mode: "none" | "static" | "endpoint" = endpoint ? "endpoint" : model ? "static" : "none";
|
|
489
585
|
const status: Record<string, unknown> = {
|
package/src/scan.ts
CHANGED
|
@@ -36,8 +36,15 @@ export interface ScanOptions {
|
|
|
36
36
|
scope?: string;
|
|
37
37
|
// Honor .gitignore files (default true — see WalkOptions.gitignore).
|
|
38
38
|
gitignore?: boolean;
|
|
39
|
+
// Directory names to skip — REPLACES the default set (see
|
|
40
|
+
// WalkOptions.ignoreDirs; compose with the IGNORE_DIRS export to extend it).
|
|
41
|
+
ignoreDirs?: string[];
|
|
39
42
|
maxBytes?: number;
|
|
40
43
|
maxFiles?: number;
|
|
44
|
+
// Per-file call-site cap for extraction (default 512, both AST and regex
|
|
45
|
+
// tiers). Raising it trades index size for call-graph recall; dedup/sort
|
|
46
|
+
// semantics are unchanged. Absent, output is byte-identical to before.
|
|
47
|
+
maxCallsPerFile?: number;
|
|
41
48
|
out?: string; // absolute output dir to exclude from the scan (self-index guard)
|
|
42
49
|
// Previous build's extraction cache (rel → {hash, record, size?, mtimeMs?}). A
|
|
43
50
|
// file whose (size,mtime) key matches skips read+hash entirely (the stat
|
|
@@ -65,6 +72,7 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
65
72
|
maxFileBytes: opts.maxBytes,
|
|
66
73
|
maxFiles: opts.maxFiles,
|
|
67
74
|
gitignore: opts.gitignore,
|
|
75
|
+
ignoreDirs: opts.ignoreDirs,
|
|
68
76
|
});
|
|
69
77
|
// Never index our own output (e.g. a committed `docs/ultraindex/`), or builds
|
|
70
78
|
// would describe the encyclopedia instead of the code.
|
|
@@ -144,7 +152,7 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
|
|
|
144
152
|
// Non-markdown prose (.rst/.txt): title from basename, no link graph.
|
|
145
153
|
record.title = basename(f.rel);
|
|
146
154
|
} else if (kind === "code") {
|
|
147
|
-
const code = extractCode(f.rel, f.ext, content);
|
|
155
|
+
const code = extractCode(f.rel, f.ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
|
|
148
156
|
record.title = basename(f.rel);
|
|
149
157
|
record.summary = code.summary;
|
|
150
158
|
record.symbols = code.symbols;
|