@maxgfr/codeindex 2.12.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -11
- package/docs/MIGRATION.md +155 -36
- package/docs/SEMANTIC.md +12 -5
- package/package.json +1 -1
- package/scripts/engine.d.mts +49 -7
- package/scripts/engine.mjs +1233 -749
- package/src/ast/extract.ts +7 -5
- package/src/ast/grammars-pull.ts +178 -0
- package/src/ast/loader.ts +77 -15
- package/src/bm25.ts +16 -6
- package/src/callers.ts +0 -0
- package/src/complexity.ts +7 -1
- package/src/deadcode.ts +5 -4
- package/src/derived.ts +128 -0
- package/src/embed/model.ts +23 -14
- package/src/engine-cli.ts +307 -51
- package/src/engine.ts +21 -4
- package/src/extract/code.ts +9 -5
- package/src/hash.ts +5 -2
- package/src/lang/js-ts.ts +1 -1
- package/src/mcp.ts +220 -26
- package/src/pipeline.ts +16 -5
- package/src/query.ts +8 -5
- package/src/scan.ts +74 -8
- package/src/types.ts +6 -3
- package/src/walk.ts +41 -11
package/src/ast/extract.ts
CHANGED
|
@@ -399,8 +399,9 @@ function readReceiver(node: TSNode | null): string | undefined {
|
|
|
399
399
|
// dedicated member-call node's `name`; "constructor" reads the constructed type.
|
|
400
400
|
// A qualified call also carries the immediate `receiver` name (see readReceiver).
|
|
401
401
|
// Names are filtered to plausible identifiers (≥ 2 chars), deduped by name+line,
|
|
402
|
-
// sorted, and capped, so the set
|
|
403
|
-
|
|
402
|
+
// sorted, and capped (default MAX_CALLS; overridable via maxCalls), so the set
|
|
403
|
+
// stays small and deterministic.
|
|
404
|
+
function collectCalls(root: TSNode, spec: LangSpec, maxCalls: number = MAX_CALLS): { name: string; line: number; receiver?: string }[] {
|
|
404
405
|
if (!spec.calls) return [];
|
|
405
406
|
const out: { name: string; line: number; receiver?: string }[] = [];
|
|
406
407
|
const seen = new Set<string>();
|
|
@@ -439,7 +440,7 @@ function collectCalls(root: TSNode, spec: LangSpec): { name: string; line: numbe
|
|
|
439
440
|
};
|
|
440
441
|
visit(root);
|
|
441
442
|
out.sort((a, b) => byStr(a.name, b.name) || a.line - b.line);
|
|
442
|
-
return out.slice(0,
|
|
443
|
+
return out.slice(0, maxCalls);
|
|
443
444
|
}
|
|
444
445
|
|
|
445
446
|
// Collect JS/TS named-import bindings: `import { a, b as c } from "x"` →
|
|
@@ -476,7 +477,8 @@ function collectImportedNames(root: TSNode, spec: LangSpec): string[] {
|
|
|
476
477
|
// Extract declared symbols from one file via its committed grammar. Returns
|
|
477
478
|
// undefined when no grammar is loaded for the extension (caller falls back to the
|
|
478
479
|
// regex extractor). Walks top-level declarations plus one level of nested members.
|
|
479
|
-
|
|
480
|
+
// `opts.maxCalls` overrides the per-file call-site cap (default MAX_CALLS).
|
|
481
|
+
export function extractAst(rel: string, ext: string, content: string, opts: { maxCalls?: number } = {}): AstResult | undefined {
|
|
480
482
|
const key = grammarKeyForExt(ext);
|
|
481
483
|
if (!key || !grammarReady(key)) return undefined;
|
|
482
484
|
const spec = SPECS[key];
|
|
@@ -697,7 +699,7 @@ export function extractAst(rel: string, ext: string, content: string): AstResult
|
|
|
697
699
|
|
|
698
700
|
const refs = collectImports(root, spec);
|
|
699
701
|
const idents = collectRefIdents(root, new Set(symbols.map((s) => s.name)));
|
|
700
|
-
const calls = collectCalls(root, spec);
|
|
702
|
+
const calls = collectCalls(root, spec, opts.maxCalls);
|
|
701
703
|
const importedNames = collectImportedNames(root, spec);
|
|
702
704
|
let pkg: string | undefined;
|
|
703
705
|
if (spec.lang === "java") {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { ENGINE_VERSION } from "../types.js";
|
|
6
|
+
|
|
7
|
+
// The slim grammars-pull tier (v2.14.0). The tree-sitter wasm sidecar
|
|
8
|
+
// (scripts/grammars/, ~22 MB) is OPTIONAL and opt-in by presence, exactly like
|
|
9
|
+
// the embedding model (src/embed/model.ts): no wasm resolvable anywhere → the
|
|
10
|
+
// engine silently stays on the regex tier (src/ast/loader.ts). This module lets
|
|
11
|
+
// a consumer that vendors ONLY the zero-dependency engine.mjs (no adjacent
|
|
12
|
+
// grammars/) fetch the same committed wasms into a shared, version-scoped cache
|
|
13
|
+
// — `codeindex grammars pull` — and get byte-identical AST extraction from the
|
|
14
|
+
// cache as from a bundle-adjacent dir (same wasm bytes → same AST → same
|
|
15
|
+
// symbols). NOTHING here runs during indexing; a failed/absent pull only ever
|
|
16
|
+
// leaves the cache empty, never throws into the scan.
|
|
17
|
+
//
|
|
18
|
+
// Zero runtime dependencies: global fetch (Node ≥18, follows GitHub → CDN
|
|
19
|
+
// redirects — mirrors `embed pull`), node:crypto for sha256, node:zlib for
|
|
20
|
+
// gunzip, and a tiny inline ustar reader below (Node has no built-in tar). We
|
|
21
|
+
// deliberately do NOT spawn the system `tar`: the embed-pull precedent is pure
|
|
22
|
+
// Node stdlib, and an in-process reader is cross-platform-deterministic and
|
|
23
|
+
// lets us guard path traversal precisely before a single byte hits disk.
|
|
24
|
+
|
|
25
|
+
// The official grammars asset, published as a normal release asset on the
|
|
26
|
+
// engine's own `v<ENGINE_VERSION>` tag (built + uploaded by the release
|
|
27
|
+
// workflow, per-version). `grammars pull` uses this when CODEINDEX_GRAMMARS_URL
|
|
28
|
+
// is unset.
|
|
29
|
+
export const DEFAULT_GRAMMARS_URL = `https://github.com/maxgfr/codeindex/releases/download/v${ENGINE_VERSION}/grammars-${ENGINE_VERSION}.tar.gz`;
|
|
30
|
+
|
|
31
|
+
// What `grammars pull` fetches, and how to verify it. CODEINDEX_GRAMMARS_URL
|
|
32
|
+
// wins outright (the user's explicit override / private mirror) and carries NO
|
|
33
|
+
// checksum, so a custom asset keeps the un-verified behavior — exactly like
|
|
34
|
+
// resolveEmbedPullUrl's custom-mirror rule. With no env we fall back to the
|
|
35
|
+
// built-in default AND its `.sha256` SIDECAR asset. Unlike the embed model (a
|
|
36
|
+
// single fixed release whose hash is pinned as a code constant), the grammars
|
|
37
|
+
// tarball is cut fresh on EVERY release, so its hash cannot be a compile-time
|
|
38
|
+
// constant; the checksum travels next to the asset and is fetched at pull time.
|
|
39
|
+
export interface GrammarsPullTarget {
|
|
40
|
+
url: string;
|
|
41
|
+
sha256Url?: string; // present only for the built-in default → fetch + verify
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveGrammarsPullTarget(): GrammarsPullTarget {
|
|
45
|
+
const env = process.env.CODEINDEX_GRAMMARS_URL;
|
|
46
|
+
if (env && env.trim()) return { url: env.trim() };
|
|
47
|
+
return { url: DEFAULT_GRAMMARS_URL, sha256Url: `${DEFAULT_GRAMMARS_URL}.sha256` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fetch the tarball bytes over HTTP, following redirects. When `expectedSha256`
|
|
51
|
+
// is given, the downloaded bytes are hashed and MUST match or this throws with
|
|
52
|
+
// a clear message (so a corrupt/tampered default asset never reaches disk). A
|
|
53
|
+
// non-2xx response also throws. I/O + verification are split from the CLI so
|
|
54
|
+
// both the success and the sha-mismatch paths are unit-testable — mirrors
|
|
55
|
+
// fetchEmbedModel.
|
|
56
|
+
export async function fetchGrammarsTarball(url: string, expectedSha256?: string): Promise<Uint8Array> {
|
|
57
|
+
const res = await fetch(url);
|
|
58
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
59
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
60
|
+
if (expectedSha256) {
|
|
61
|
+
const got = createHash("sha256").update(buf).digest("hex");
|
|
62
|
+
if (got !== expectedSha256) {
|
|
63
|
+
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${got}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return buf;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Wrap any Uint8Array as a Buffer VIEW over the same memory (no copy) so the
|
|
70
|
+
// tar reader can use Buffer's string decoders. Keeps the public API on
|
|
71
|
+
// Uint8Array — the engine's convention — while avoiding a Node-only `Buffer`
|
|
72
|
+
// type in the shipped .d.mts (consumers may lack @types/node).
|
|
73
|
+
function asBuffer(u: Uint8Array): Buffer {
|
|
74
|
+
return Buffer.isBuffer(u) ? u : Buffer.from(u.buffer, u.byteOffset, u.byteLength);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fetch + parse the `.sha256` sidecar (a `<hex>` or `<hex> <filename>` line),
|
|
78
|
+
// returning the lowercased 64-hex digest. Throws on a non-2xx or a malformed
|
|
79
|
+
// body so a bad sidecar degrades the pull to a clear error rather than silently
|
|
80
|
+
// skipping verification.
|
|
81
|
+
export async function fetchExpectedSha256(url: string): Promise<string> {
|
|
82
|
+
const res = await fetch(url);
|
|
83
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);
|
|
84
|
+
const text = await res.text();
|
|
85
|
+
const hex = (text.trim().split(/\s+/)[0] ?? "").toLowerCase();
|
|
86
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`invalid sha256 sidecar at ${url}`);
|
|
87
|
+
return hex;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// One parsed tar entry (header fields we care about + its raw data slice).
|
|
91
|
+
interface TarEntry {
|
|
92
|
+
name: string;
|
|
93
|
+
type: string; // typeflag char: "0"/"\0" = regular file, "5" = dir, …
|
|
94
|
+
data: Buffer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read a C-string (NUL-terminated, else the whole field) from a 512-byte header.
|
|
98
|
+
function cstr(block: Buffer, start: number, len: number): string {
|
|
99
|
+
const slice = block.subarray(start, start + len);
|
|
100
|
+
const nul = slice.indexOf(0);
|
|
101
|
+
return slice.toString("utf8", 0, nul === -1 ? slice.length : nul);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A minimal ustar/gnu reader — enough for the flat, short-named wasm archive we
|
|
105
|
+
// publish (no long-name/PAX/sparse entries). Yields every entry in order; the
|
|
106
|
+
// caller decides which to keep. Stops at the end-of-archive zero block.
|
|
107
|
+
function* readTar(buf: Buffer): Generator<TarEntry> {
|
|
108
|
+
let off = 0;
|
|
109
|
+
while (off + 512 <= buf.length) {
|
|
110
|
+
const block = buf.subarray(off, off + 512);
|
|
111
|
+
let allZero = true;
|
|
112
|
+
for (let i = 0; i < 512; i++) {
|
|
113
|
+
if (block[i] !== 0) {
|
|
114
|
+
allZero = false;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (allZero) break; // end-of-archive marker
|
|
119
|
+
const name = cstr(block, 0, 100);
|
|
120
|
+
const prefix = cstr(block, 345, 155); // ustar long-path prefix (usually empty)
|
|
121
|
+
const sizeStr = cstr(block, 124, 12).trim();
|
|
122
|
+
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
|
|
123
|
+
const type = String.fromCharCode(block[156] ?? 0);
|
|
124
|
+
off += 512;
|
|
125
|
+
const data = buf.subarray(off, off + size);
|
|
126
|
+
off += Math.ceil(size / 512) * 512;
|
|
127
|
+
yield { name: prefix ? `${prefix}/${name}` : name, type, data };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normalize a tar entry name to a SAFE relative path, or null to reject it.
|
|
132
|
+
// Rejects absolute paths (POSIX "/…", Windows "C:…" or "\…"), any ".." segment,
|
|
133
|
+
// and embedded NULs; drops "." and empty segments. This is the path-traversal
|
|
134
|
+
// guard: a "../../etc/x" or "/etc/x" entry can never escape the destination.
|
|
135
|
+
function safeRelPath(name: string): string | null {
|
|
136
|
+
if (!name || name.includes("\0")) return null;
|
|
137
|
+
if (name.startsWith("/") || name.startsWith("\\") || /^[A-Za-z]:/.test(name)) return null;
|
|
138
|
+
const out: string[] = [];
|
|
139
|
+
for (const part of name.split(/[/\\]/)) {
|
|
140
|
+
if (part === "" || part === ".") continue;
|
|
141
|
+
if (part === "..") return null;
|
|
142
|
+
out.push(part);
|
|
143
|
+
}
|
|
144
|
+
return out.length ? out.join("/") : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Extract every regular file from a RAW (already-gunzipped) tar into destDir,
|
|
148
|
+
// returning the relative paths written. Throws on the FIRST unsafe entry BEFORE
|
|
149
|
+
// writing it (defense in depth: also verifies the resolved path stays within
|
|
150
|
+
// destDir). Non-regular entries (dirs, symlinks, headers) are skipped. Callers
|
|
151
|
+
// extract into a throwaway tmp dir, so a mid-stream throw leaves nothing
|
|
152
|
+
// half-installed once that tmp dir is discarded.
|
|
153
|
+
export function extractTarInto(rawTar: Uint8Array, destDir: string): string[] {
|
|
154
|
+
const root = resolve(destDir);
|
|
155
|
+
const written: string[] = [];
|
|
156
|
+
for (const entry of readTar(asBuffer(rawTar))) {
|
|
157
|
+
if (entry.type !== "0" && entry.type !== "\0") continue; // regular files only
|
|
158
|
+
const rel = safeRelPath(entry.name);
|
|
159
|
+
if (rel === null) throw new Error(`refusing unsafe tar entry: ${entry.name}`);
|
|
160
|
+
const dest = resolve(destDir, rel);
|
|
161
|
+
if (dest !== root && !dest.startsWith(root + sep)) {
|
|
162
|
+
throw new Error(`tar entry escapes destination: ${entry.name}`);
|
|
163
|
+
}
|
|
164
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
165
|
+
writeFileSync(dest, entry.data);
|
|
166
|
+
written.push(rel);
|
|
167
|
+
}
|
|
168
|
+
return written;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// gunzip (when the bytes are gzip-framed) then extract. The default asset is a
|
|
172
|
+
// `.tar.gz`; a custom CODEINDEX_GRAMMARS_URL serving a plain `.tar` is handled
|
|
173
|
+
// too (magic-byte sniff), so the tier does not force a compression format.
|
|
174
|
+
export function extractGrammarsTarball(bytes: Uint8Array, destDir: string): string[] {
|
|
175
|
+
const b = asBuffer(bytes);
|
|
176
|
+
const raw = b.length >= 2 && b[0] === 0x1f && b[1] === 0x8b ? gunzipSync(b) : b;
|
|
177
|
+
return extractTarInto(raw, destDir);
|
|
178
|
+
}
|
package/src/ast/loader.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { Parser, Language } from "web-tree-sitter";
|
|
6
|
+
import { ENGINE_VERSION } from "../types.js";
|
|
5
7
|
|
|
6
8
|
// Extension → committed grammar wasm key (scripts/grammars/<key>.wasm). Only the
|
|
7
9
|
// languages we ship a grammar for appear here; everything else falls back to the
|
|
@@ -28,23 +30,65 @@ export function grammarKeyForExt(ext: string): string | undefined {
|
|
|
28
30
|
return EXT_GRAMMAR[ext];
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
// Which supplier furnished the resolved grammars dir. Reported by
|
|
34
|
+
// `codeindex grammars status`; "none" is the regex-tier signal.
|
|
35
|
+
export type GrammarsTierName = "adjacent" | "env" | "cache" | "none";
|
|
36
|
+
|
|
37
|
+
export interface GrammarsTier {
|
|
38
|
+
tier: GrammarsTierName;
|
|
39
|
+
dir?: string; // undefined only when tier === "none"
|
|
40
|
+
cacheDir: string; // where a `grammars pull` would extract, regardless of tier
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The shared, version-scoped cache a `grammars pull` extracts into and the
|
|
44
|
+
// resolver falls back to when no wasm ships next to the bundle. Version-scoped
|
|
45
|
+
// so a consumer bumping the engine never loads a stale grammar set: a new
|
|
46
|
+
// ENGINE_VERSION points at a fresh, empty dir until its own pull runs (and the
|
|
47
|
+
// old dir's bytes remain byte-identical to what that engine shipped). Honors
|
|
48
|
+
// XDG_CACHE_HOME, else ~/.cache — the platform-neutral, dependency-free
|
|
49
|
+
// convention already used by the wider toolchain.
|
|
50
|
+
export function sharedGrammarsCacheDir(): string {
|
|
51
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
52
|
+
const base = xdg && xdg.trim() ? xdg.trim() : join(homedir(), ".cache");
|
|
53
|
+
return join(base, "codeindex", "grammars", ENGINE_VERSION);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve the grammars dir AND record which tier supplied it, IN ORDER:
|
|
57
|
+
// 1. an explicit CODEINDEX_GRAMMAR_DIR / ULTRAINDEX_GRAMMAR_DIR override
|
|
58
|
+
// (legacy, singular; kept winning outright so vendored/test setups that
|
|
59
|
+
// pin it behave exactly as before) — reported as the "env" tier;
|
|
60
|
+
// 2. (a) the bundle-adjacent grammars/ dir — the shipped default: works from
|
|
61
|
+
// the tsup bundle (scripts/engine.mjs → scripts/grammars), a consumer's
|
|
62
|
+
// vendored copy (src/vendor → ../../scripts/grammars) or source under
|
|
63
|
+
// vitest (src/ast → ../../scripts/grammars). Wins if present, so the
|
|
64
|
+
// offline, no-network story is untouched;
|
|
65
|
+
// 3. (b) CODEINDEX_GRAMMARS_DIR — an explicit shared/custom dir override;
|
|
66
|
+
// 4. (c) the shared version-scoped cache a `grammars pull` populates;
|
|
67
|
+
// 5. (d) nothing resolvable → tier "none", dir undefined → the regex tier.
|
|
68
|
+
// Never touches the network and never throws. `moduleDir` overrides the
|
|
69
|
+
// module-relative base of the bundle-adjacent probe (tests/tooling only).
|
|
70
|
+
export function resolveGrammarsTier(opts: { moduleDir?: string } = {}): GrammarsTier {
|
|
71
|
+
const cacheDir = sharedGrammarsCacheDir();
|
|
72
|
+
const legacy = process.env.CODEINDEX_GRAMMAR_DIR ?? process.env.ULTRAINDEX_GRAMMAR_DIR;
|
|
73
|
+
if (legacy && legacy.trim() && existsSync(legacy)) return { tier: "env", dir: legacy, cacheDir };
|
|
74
|
+
const here = opts.moduleDir ?? dirname(fileURLToPath(import.meta.url));
|
|
75
|
+
const adjacent = [
|
|
42
76
|
join(here, "grammars"), // bundle: <...>/scripts/grammars
|
|
43
77
|
join(here, "..", "..", "scripts", "grammars"), // dev: src/ast → <repo>/scripts/grammars
|
|
44
78
|
join(here, "..", "scripts", "grammars"),
|
|
45
79
|
];
|
|
46
|
-
for (const c of
|
|
47
|
-
|
|
80
|
+
for (const c of adjacent) if (existsSync(c)) return { tier: "adjacent", dir: c, cacheDir };
|
|
81
|
+
const env = process.env.CODEINDEX_GRAMMARS_DIR;
|
|
82
|
+
if (env && env.trim() && existsSync(env)) return { tier: "env", dir: env, cacheDir };
|
|
83
|
+
if (existsSync(cacheDir)) return { tier: "cache", dir: cacheDir, cacheDir };
|
|
84
|
+
return { tier: "none", cacheDir };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The chosen grammars dir, or undefined when nothing is resolvable anywhere
|
|
88
|
+
// (the caller then stays on the regex tier). Additive companion to
|
|
89
|
+
// resolveGrammarsTier — same resolution, dir only.
|
|
90
|
+
export function resolveGrammarsDir(opts?: { moduleDir?: string }): string | undefined {
|
|
91
|
+
return resolveGrammarsTier(opts).dir;
|
|
48
92
|
}
|
|
49
93
|
|
|
50
94
|
// tree-sitter's runtime + grammars must be initialised asynchronously (wasm
|
|
@@ -62,10 +106,11 @@ const failed = new Set<string>();
|
|
|
62
106
|
// and safe to call repeatedly. A missing/broken wasm is remembered as failed so
|
|
63
107
|
// the caller silently falls back to regex rather than retrying every file.
|
|
64
108
|
export async function ensureGrammars(keys: Iterable<string>): Promise<void> {
|
|
65
|
-
const dir =
|
|
109
|
+
const dir = resolveGrammarsDir();
|
|
110
|
+
if (!dir) return; // nothing resolvable (adjacent/env/cache all absent) → regex everywhere
|
|
66
111
|
if (!runtimeReady) {
|
|
67
112
|
const runtime = join(dir, "web-tree-sitter.wasm");
|
|
68
|
-
if (!existsSync(runtime)) return; // no
|
|
113
|
+
if (!existsSync(runtime)) return; // dir present but no runtime wasm → regex fallback everywhere
|
|
69
114
|
await Parser.init({ wasmBinary: readFileSync(runtime) as unknown as Uint8Array });
|
|
70
115
|
runtimeReady = true;
|
|
71
116
|
parser = new Parser();
|
|
@@ -90,6 +135,23 @@ export function allGrammarKeys(): string[] {
|
|
|
90
135
|
return [...new Set(Object.values(EXT_GRAMMAR))];
|
|
91
136
|
}
|
|
92
137
|
|
|
138
|
+
// The grammar keys needed for a set of file extensions: each mapped through
|
|
139
|
+
// EXT_GRAMMAR, unknown extensions dropped, then deduped and sorted. Warming
|
|
140
|
+
// exactly this set (instead of every committed grammar) skips the wasm load for
|
|
141
|
+
// languages the repo doesn't contain, while keeping output byte-identical:
|
|
142
|
+
// extractAst falls back to regex only when grammarReady(key) is false, and the
|
|
143
|
+
// walk's extension set (which feeds this) is a superset of what scanRepo keeps,
|
|
144
|
+
// so every extracted file has its grammar loaded. Language.load calls are
|
|
145
|
+
// independent, so loading fewer grammars cannot change parses of loaded ones.
|
|
146
|
+
export function grammarKeysForExts(exts: Iterable<string>): string[] {
|
|
147
|
+
const keys = new Set<string>();
|
|
148
|
+
for (const ext of exts) {
|
|
149
|
+
const key = EXT_GRAMMAR[ext];
|
|
150
|
+
if (key !== undefined) keys.add(key);
|
|
151
|
+
}
|
|
152
|
+
return [...keys].sort();
|
|
153
|
+
}
|
|
154
|
+
|
|
93
155
|
export function grammarReady(key: string): boolean {
|
|
94
156
|
return loaded.has(key);
|
|
95
157
|
}
|
package/src/bm25.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// query byte-identical: the expansion only ever engages on terms that would
|
|
18
18
|
// otherwise contribute nothing.
|
|
19
19
|
import type { RepoScan } from "./scan.js";
|
|
20
|
+
import { bm25DocsFor, bm25TrigramsFor } from "./derived.js";
|
|
20
21
|
import { foldText, keywords } from "./util.js";
|
|
21
22
|
import { byStr } from "./sort.js";
|
|
22
23
|
|
|
@@ -72,7 +73,10 @@ export function subtokens(raw: string): string[] {
|
|
|
72
73
|
return out;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
// Exported for src/derived.ts's per-scan cache only — NOT part of the public
|
|
77
|
+
// barrel (engine.ts). searchIndex reads docs strictly, never mutates them, so
|
|
78
|
+
// the cached array/objects can be shared across calls on the same scan.
|
|
79
|
+
export interface Doc {
|
|
76
80
|
file: string;
|
|
77
81
|
tf: Map<string, number>;
|
|
78
82
|
len: number; // total token occurrences (the BM25 length normalizer)
|
|
@@ -86,7 +90,8 @@ function addTerms(doc: Doc, text: string): void {
|
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
|
|
93
|
+
// Exported for src/derived.ts (bm25DocsFor) — not in the public barrel.
|
|
94
|
+
export function buildDocs(scan: RepoScan): Doc[] {
|
|
90
95
|
const docs: Doc[] = [];
|
|
91
96
|
for (const f of scan.files) {
|
|
92
97
|
const doc: Doc = { file: f.rel, tf: new Map(), len: 0, symbols: [] };
|
|
@@ -128,8 +133,9 @@ export function diceCoefficient(a: ReadonlySet<string>, b: ReadonlySet<string>):
|
|
|
128
133
|
|
|
129
134
|
// Trigram index of the corpus vocabulary: every distinct doc token mapped to
|
|
130
135
|
// its trigram set. Built LAZILY by searchIndex — only when >=1 query term has
|
|
131
|
-
// df==0 — so a fully-matched query never pays this cost.
|
|
132
|
-
|
|
136
|
+
// df==0 — so a fully-matched query never pays this cost. Exported for
|
|
137
|
+
// src/derived.ts (bm25TrigramsFor) — not in the public barrel.
|
|
138
|
+
export function buildTrigramIndex(docs: Doc[]): Map<string, Set<string>> {
|
|
133
139
|
const index = new Map<string, Set<string>>();
|
|
134
140
|
for (const d of docs) {
|
|
135
141
|
for (const term of d.tf.keys()) {
|
|
@@ -155,7 +161,9 @@ export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions =
|
|
|
155
161
|
}
|
|
156
162
|
if (!terms.length) return [];
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
// Memoized per scan (src/derived.ts) — identical docs to a direct build;
|
|
165
|
+
// read-only from here on.
|
|
166
|
+
const docs = bm25DocsFor(scan);
|
|
159
167
|
const n = docs.length;
|
|
160
168
|
if (!n) return [];
|
|
161
169
|
let totalLen = 0;
|
|
@@ -179,7 +187,9 @@ export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions =
|
|
|
179
187
|
if (fuzzyEnabled) {
|
|
180
188
|
const unmatched = terms.filter((t) => df.get(t) === 0);
|
|
181
189
|
if (unmatched.length) {
|
|
182
|
-
|
|
190
|
+
// Still lazy (only reached on a zero-df term), now also cached per scan:
|
|
191
|
+
// the first fuzzy query builds the vocabulary trigrams, later ones reuse.
|
|
192
|
+
const trigramIndex = bm25TrigramsFor(scan);
|
|
183
193
|
for (const t of unmatched) {
|
|
184
194
|
const grams = charTrigrams(t);
|
|
185
195
|
const candidates: { term: string; dice: number }[] = [];
|
package/src/callers.ts
CHANGED
|
Binary file
|
package/src/complexity.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// changed are where defects concentrate.
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import type { RepoScan } from "./scan.js";
|
|
8
|
+
import { fileComplexityFor } from "./derived.js";
|
|
8
9
|
import { readText } from "./walk.js";
|
|
9
10
|
import { byStr } from "./sort.js";
|
|
10
11
|
|
|
@@ -57,10 +58,15 @@ export interface RiskHotspot {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export function riskHotspots(scan: RepoScan, churn: Map<string, number>, top = 20): RiskHotspot[] {
|
|
61
|
+
// Per-file branch counts memoized per scan (src/derived.ts): the first call
|
|
62
|
+
// still reads every code file from disk, repeat calls become map lookups.
|
|
63
|
+
// fileComplexityFor covers exactly the code files filtered below, so the
|
|
64
|
+
// lookup always hits.
|
|
65
|
+
const complexityByFile = fileComplexityFor(scan);
|
|
60
66
|
const out: RiskHotspot[] = scan.files
|
|
61
67
|
.filter((f) => f.kind === "code")
|
|
62
68
|
.map((f) => {
|
|
63
|
-
const complexity =
|
|
69
|
+
const complexity = complexityByFile.get(f.rel)!;
|
|
64
70
|
const commits = churn.get(f.rel) ?? 0;
|
|
65
71
|
return { file: f.rel, complexity, commits, score: (commits + 1) * complexity };
|
|
66
72
|
});
|
package/src/deadcode.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// consumed externally), not by substring matching.
|
|
8
8
|
import type { CodeSymbol } from "./types.js";
|
|
9
9
|
import type { RepoScan } from "./scan.js";
|
|
10
|
-
import {
|
|
11
|
-
import { computeSymbolRefs } from "./render/symbols-json.js";
|
|
10
|
+
import { callerIndexFor, symbolRefsFor } from "./derived.js";
|
|
12
11
|
import { isTestPath } from "./tests-map.js";
|
|
13
12
|
import { byStr } from "./sort.js";
|
|
14
13
|
|
|
@@ -26,8 +25,10 @@ export interface DeadSymbol {
|
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
export function findDeadCode(scan: RepoScan): DeadSymbol[] {
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Memoized per scan (src/derived.ts) and READ-ONLY here — this function
|
|
29
|
+
// only .get()s from both structures, so sharing the cached objects is safe.
|
|
30
|
+
const callers = callerIndexFor(scan);
|
|
31
|
+
const refs = symbolRefsFor(scan);
|
|
31
32
|
const out: DeadSymbol[] = [];
|
|
32
33
|
const consider = (s: CodeSymbol): boolean =>
|
|
33
34
|
s.exported && !REFERENCE_KINDS.has(s.kind) && !isTestPath(s.file) && !ENTRYPOINT_RE.test(s.file);
|
package/src/derived.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Per-scan derived-structure cache (INTERNAL — deliberately NOT exported from
|
|
2
|
+
// the src/engine.ts barrel). Several public entry points independently
|
|
3
|
+
// recompute the same expensive structures from the same RepoScan object:
|
|
4
|
+
// findReferences, findDeadCode and the pipeline each rebuild the caller index
|
|
5
|
+
// / symbol refs; computeImportPairs rebuilds the resolve context; searchIndex
|
|
6
|
+
// rebuilds the BM25 documents; riskHotspots re-reads every code file. This
|
|
7
|
+
// module memoizes those structures once per scan, keyed by RepoScan object
|
|
8
|
+
// identity in a WeakMap — a scan's cache lives exactly as long as the scan
|
|
9
|
+
// itself, and two scans (even of the same repo) never share entries.
|
|
10
|
+
//
|
|
11
|
+
// Determinism: every builder memoized here is a pure function of the scan
|
|
12
|
+
// (plus, for the resolve context and per-file complexity, the on-disk bytes
|
|
13
|
+
// the scan describes), so a cache hit returns exactly what a recompute would —
|
|
14
|
+
// artifacts stay byte-identical; only repeated work disappears.
|
|
15
|
+
//
|
|
16
|
+
// Sharing contract: accessors return the SAME object on every call for a
|
|
17
|
+
// given scan. Callers must treat the results as READ-ONLY and copy anything
|
|
18
|
+
// they hand to a consumer that owns its result (findReferences copies
|
|
19
|
+
// callSites; computeImportPairs returns a fresh Set) — a consumer mutation
|
|
20
|
+
// reaching a cached object would poison every later query on that scan.
|
|
21
|
+
//
|
|
22
|
+
// Only the DEFAULT (precision) caller index is memoized: recall-mode variants
|
|
23
|
+
// take options, and buildCallerIndex's public path keeps building a fresh
|
|
24
|
+
// index per call (it only shares the memoized import pairs).
|
|
25
|
+
//
|
|
26
|
+
// Import cycles: callers.ts / bm25.ts / complexity.ts import their accessor
|
|
27
|
+
// from here while this module imports their builders — function-level cycles
|
|
28
|
+
// only (no module-evaluation-time cross-calls), which Node ESM and esbuild
|
|
29
|
+
// resolve safely.
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import type { RepoScan } from "./scan.js";
|
|
32
|
+
import { buildResolveContext, resolveImport, type ResolveContext } from "./resolve.js";
|
|
33
|
+
import { uniqueSymbolDefs } from "./graph.js";
|
|
34
|
+
import { computeSymbolRefs } from "./render/symbols-json.js";
|
|
35
|
+
import { buildCallerIndex, type CallerIndex } from "./callers.js";
|
|
36
|
+
import { buildDocs, buildTrigramIndex, type Doc } from "./bm25.js";
|
|
37
|
+
import { complexityOfSource } from "./complexity.js";
|
|
38
|
+
import { readText } from "./walk.js";
|
|
39
|
+
|
|
40
|
+
interface DerivedCache {
|
|
41
|
+
resolveCtx?: ResolveContext;
|
|
42
|
+
importPairs?: Set<string>; // `${from}|${to}` resolved-import pairs
|
|
43
|
+
uniqueDefs?: Map<string, string>; // uniqueSymbolDefs(scan)
|
|
44
|
+
symbolRefs?: Map<string, Set<string>>; // computeSymbolRefs(scan)
|
|
45
|
+
callerIndex?: CallerIndex; // DEFAULT precision opts only — never recall mode
|
|
46
|
+
bm25?: { docs: Doc[]; trigrams?: Map<string, Set<string>> };
|
|
47
|
+
fileComplexity?: Map<string, number>; // rel → whole-file branch count + 1 (code files)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const caches = new WeakMap<RepoScan, DerivedCache>();
|
|
51
|
+
|
|
52
|
+
function cacheFor(scan: RepoScan): DerivedCache {
|
|
53
|
+
let c = caches.get(scan);
|
|
54
|
+
if (!c) caches.set(scan, (c = {}));
|
|
55
|
+
return c;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveContextFor(scan: RepoScan): ResolveContext {
|
|
59
|
+
const c = cacheFor(scan);
|
|
60
|
+
return (c.resolveCtx ??= buildResolveContext(scan));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The same pair set computeImportPairs (callers.ts) historically built —
|
|
64
|
+
// computed here against the memoized resolve context so the public function
|
|
65
|
+
// can delegate (returning a fresh Set) without a recompute per call.
|
|
66
|
+
export function importPairsFor(scan: RepoScan): Set<string> {
|
|
67
|
+
const c = cacheFor(scan);
|
|
68
|
+
if (!c.importPairs) {
|
|
69
|
+
const ctx = resolveContextFor(scan);
|
|
70
|
+
const pairs = new Set<string>();
|
|
71
|
+
for (const f of scan.files) {
|
|
72
|
+
for (const ref of f.refs) {
|
|
73
|
+
if (ref.kind !== "import") continue;
|
|
74
|
+
const r = resolveImport(f.rel, f.ext, ref.spec, ctx);
|
|
75
|
+
if (r.kind === "resolved" && r.target !== f.rel) pairs.add(`${f.rel}|${r.target}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
c.importPairs = pairs;
|
|
79
|
+
}
|
|
80
|
+
return c.importPairs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function uniqueDefsFor(scan: RepoScan): Map<string, string> {
|
|
84
|
+
const c = cacheFor(scan);
|
|
85
|
+
return (c.uniqueDefs ??= uniqueSymbolDefs(scan));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function symbolRefsFor(scan: RepoScan): Map<string, Set<string>> {
|
|
89
|
+
const c = cacheFor(scan);
|
|
90
|
+
return (c.symbolRefs ??= computeSymbolRefs(scan));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Default (precision) caller index only. Recall-mode indexes are option-
|
|
94
|
+
// dependent and stay unmemoized on buildCallerIndex's public path.
|
|
95
|
+
export function callerIndexFor(scan: RepoScan): CallerIndex {
|
|
96
|
+
const c = cacheFor(scan);
|
|
97
|
+
return (c.callerIndex ??= buildCallerIndex(scan, importPairsFor(scan)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function bm25DocsFor(scan: RepoScan): Doc[] {
|
|
101
|
+
const c = cacheFor(scan);
|
|
102
|
+
return (c.bm25 ??= { docs: buildDocs(scan) }).docs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The corpus-vocabulary trigram index stays LAZY: built only when searchIndex
|
|
106
|
+
// first meets a zero-df query term (the pre-cache behavior), then cached so
|
|
107
|
+
// later fuzzy queries on the same scan skip the rebuild.
|
|
108
|
+
export function bm25TrigramsFor(scan: RepoScan): Map<string, Set<string>> {
|
|
109
|
+
const c = cacheFor(scan);
|
|
110
|
+
const bm25 = (c.bm25 ??= { docs: buildDocs(scan) });
|
|
111
|
+
return (bm25.trigrams ??= buildTrigramIndex(bm25.docs));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whole-file branch counts for every code file (riskHotspots' per-file
|
|
115
|
+
// complexity). The FIRST call still reads each code file from disk — accepted;
|
|
116
|
+
// repeat calls on the same scan become lookups.
|
|
117
|
+
export function fileComplexityFor(scan: RepoScan): Map<string, number> {
|
|
118
|
+
const c = cacheFor(scan);
|
|
119
|
+
if (!c.fileComplexity) {
|
|
120
|
+
const m = new Map<string, number>();
|
|
121
|
+
for (const f of scan.files) {
|
|
122
|
+
if (f.kind !== "code") continue;
|
|
123
|
+
m.set(f.rel, complexityOfSource(readText(join(scan.root, f.rel))));
|
|
124
|
+
}
|
|
125
|
+
c.fileComplexity = m;
|
|
126
|
+
}
|
|
127
|
+
return c.fileComplexity;
|
|
128
|
+
}
|
package/src/embed/model.ts
CHANGED
|
@@ -87,20 +87,17 @@ export function hasEmbedModel(repo?: string): boolean {
|
|
|
87
87
|
return resolveEmbedModelDir(repo) !== undefined;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
const { modelId, dim, vocab, weights } = raw;
|
|
100
|
-
if (typeof modelId !== "string" || !modelId) throw new Error(`embed model: missing modelId in ${path}`);
|
|
101
|
-
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}`);
|
|
102
99
|
if (!Array.isArray(vocab) || !Array.isArray(weights) || vocab.length !== weights.length) {
|
|
103
|
-
throw new Error(`embed model: vocab/weights length mismatch in ${
|
|
100
|
+
throw new Error(`embed model: vocab/weights length mismatch in ${source}`);
|
|
104
101
|
}
|
|
105
102
|
const vocabSize = vocab.length;
|
|
106
103
|
const flat = new Float64Array(vocabSize * dim);
|
|
@@ -115,11 +112,23 @@ export function loadEmbedModel(dir?: string): StaticEmbedModel | undefined {
|
|
|
115
112
|
}
|
|
116
113
|
for (let d = 0; d < dim; d++) flat[i * dim + d] = Number(row[d]);
|
|
117
114
|
}
|
|
118
|
-
const unk = typeof
|
|
115
|
+
const unk = typeof rawUnk === "string" ? rawUnk : "[UNK]";
|
|
119
116
|
const unkId = vmap.has(unk) ? vmap.get(unk)! : -1;
|
|
120
117
|
return { modelId, dim, unk, unkId, vocabSize, vocab: vmap, weights: flat };
|
|
121
118
|
}
|
|
122
119
|
|
|
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
|
+
|
|
123
132
|
// What `embed pull` fetches, and whether to verify it. CODEINDEX_EMBED_URL wins
|
|
124
133
|
// outright (the user's explicit override / private mirror) and carries NO
|
|
125
134
|
// sha256, so a custom asset keeps the current un-verified behavior. With no env,
|