@maxgfr/codeindex 2.13.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 +30 -10
- package/docs/MIGRATION.md +118 -36
- package/package.json +1 -1
- package/scripts/engine.d.mts +39 -4
- package/scripts/engine.mjs +1116 -664
- 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 +190 -23
- 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
|
@@ -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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { CodeSymbol } from "./types.js";
|
|
15
15
|
import type { RepoScan } from "./scan.js";
|
|
16
16
|
import { familyOf, pickCandidate, type Cand } from "./calls.js";
|
|
17
|
-
import {
|
|
17
|
+
import { importPairsFor } from "./derived.js";
|
|
18
18
|
import { byStr } from "./sort.js";
|
|
19
19
|
|
|
20
20
|
const REFERENCE_KINDS = new Set(["reexport", "reexport-all", "default"]);
|
|
@@ -48,19 +48,12 @@ export interface CallerEntry {
|
|
|
48
48
|
export type CallerIndex = Map<string, CallerEntry>;
|
|
49
49
|
|
|
50
50
|
// `${from}|${to}` pairs of resolved imports — the same corroboration set the
|
|
51
|
-
// graph builder feeds resolveCallEdges. Computed
|
|
52
|
-
// callers that already ran the graph can pass their own set
|
|
51
|
+
// graph builder feeds resolveCallEdges. Computed (and memoized) per scan in
|
|
52
|
+
// src/derived.ts; callers that already ran the graph can pass their own set
|
|
53
|
+
// instead. Returns a FRESH Set — the public contract is that the caller owns
|
|
54
|
+
// the result, so a consumer mutation must never reach the cached set.
|
|
53
55
|
export function computeImportPairs(scan: RepoScan): Set<string> {
|
|
54
|
-
|
|
55
|
-
const pairs = new Set<string>();
|
|
56
|
-
for (const f of scan.files) {
|
|
57
|
-
for (const ref of f.refs) {
|
|
58
|
-
if (ref.kind !== "import") continue;
|
|
59
|
-
const r = resolveImport(f.rel, f.ext, ref.spec, ctx);
|
|
60
|
-
if (r.kind === "resolved" && r.target !== f.rel) pairs.add(`${f.rel}|${r.target}`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return pairs;
|
|
56
|
+
return new Set(importPairsFor(scan));
|
|
64
57
|
}
|
|
65
58
|
|
|
66
59
|
export function buildCallerIndex(
|
|
@@ -68,7 +61,10 @@ export function buildCallerIndex(
|
|
|
68
61
|
importPairs?: Set<string>,
|
|
69
62
|
opts: CallerIndexOptions = {},
|
|
70
63
|
): CallerIndex {
|
|
71
|
-
|
|
64
|
+
// Read-only use of the memoized pair set (no copy needed: only .has below).
|
|
65
|
+
// The INDEX built here is always fresh — the public path never memoizes it;
|
|
66
|
+
// the default-opts cache lives in derived.ts's callerIndexFor.
|
|
67
|
+
const pairs = importPairs ?? importPairsFor(scan);
|
|
72
68
|
const recall = opts.recall === true;
|
|
73
69
|
|
|
74
70
|
// name → def sites (first symbol per (name, file) wins, like resolveCallEdges).
|
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
|
+
}
|