@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.
@@ -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
- // Where the committed wasms live. Resolved relative to this module so the
32
- // SAME logic works whether we run from the tsup bundle (scripts/engine.mjs →
33
- // scripts/grammars), from a consumer's vendored copy (src/vendor
34
- // ../../scripts/grammars), or from source under vitest (src/ast →
35
- // ../../scripts/grammars). CODEINDEX_GRAMMAR_DIR overrides for tests/tooling
36
- // (ULTRAINDEX_GRAMMAR_DIR kept as a legacy alias).
37
- function resolveGrammarDir(): string {
38
- const env = process.env.CODEINDEX_GRAMMAR_DIR ?? process.env.ULTRAINDEX_GRAMMAR_DIR;
39
- if (env && existsSync(env)) return env;
40
- const here = dirname(fileURLToPath(import.meta.url));
41
- const candidates = [
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 candidates) if (existsSync(c)) return c;
47
- return join(here, "grammars");
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 = resolveGrammarDir();
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 committed grammars → regex fallback everywhere
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
- interface Doc {
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
- function buildDocs(scan: RepoScan): Doc[] {
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
- function buildTrigramIndex(docs: Doc[]): Map<string, Set<string>> {
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
- const docs = buildDocs(scan);
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
- const trigramIndex = buildTrigramIndex(docs);
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 { buildResolveContext, resolveImport } from "./resolve.js";
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 here for standalone use;
52
- // callers that already ran the graph can pass their own set instead.
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
- const ctx = buildResolveContext(scan);
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
- const pairs = importPairs ?? computeImportPairs(scan);
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 = complexityOfSource(readText(join(scan.root, f.rel)));
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 { buildCallerIndex } from "./callers.js";
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
- const callers = buildCallerIndex(scan);
30
- const refs = computeSymbolRefs(scan);
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
+ }