From 5de9639c950082d4610602f5f3e463276baf092b Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:02:21 +0300 Subject: [PATCH 1/8] fix(resolution): bound synthesis memory --- __tests__/integration/lru-cache.test.ts | 38 +++++++ src/resolution/c-fnptr-synthesizer.ts | 138 +++++++++++++++++++----- src/resolution/callback-synthesizer.ts | 38 ++++--- src/resolution/index.ts | 17 ++- src/resolution/lru-cache.ts | 39 ++++++- 5 files changed, 226 insertions(+), 44 deletions(-) diff --git a/__tests__/integration/lru-cache.test.ts b/__tests__/integration/lru-cache.test.ts index 8156760ae..56accf326 100644 --- a/__tests__/integration/lru-cache.test.ts +++ b/__tests__/integration/lru-cache.test.ts @@ -82,6 +82,44 @@ describe('LRUCache', () => { expect(() => new LRUCache(NaN)).toThrow(); }); + it('evicts by retained weight as well as entry count', () => { + const cache = new LRUCache(100, { + maxWeight: 10, + weightOf: (value) => value.length, + }); + cache.set('a', '1234'); + cache.set('b', '5678'); + expect(cache.get('a')).toBe('1234'); // refresh a; b is now oldest + cache.set('c', '9012'); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe('1234'); + expect(cache.get('c')).toBe('9012'); + }); + + it('does not retain a single entry larger than the weight budget', () => { + const cache = new LRUCache(10, { + maxWeight: 4, + weightOf: (value) => value.length, + }); + cache.set('too-large', '12345'); + expect(cache.size).toBe(0); + }); + + it('updates weight accounting on replacement and clear', () => { + const cache = new LRUCache(10, { + maxWeight: 6, + weightOf: (value) => value.length, + }); + cache.set('a', '12345'); + cache.set('a', '1'); + cache.set('b', '23456'); + expect(cache.get('a')).toBe('1'); + expect(cache.get('b')).toBe('23456'); + cache.clear(); + cache.set('c', '123456'); + expect(cache.get('c')).toBe('123456'); + }); + it('stays bounded under heavy churn (regression for OOM scenario)', () => { const cache = new LRUCache(100); for (let i = 0; i < 10_000; i++) { diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 568b782c9..9d9a760d1 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -178,6 +178,12 @@ interface MacroDef { expansion: string; } +/** Regex captures are sliced strings in V8. A cached tiny macro capture would + * otherwise pin its whole multi-megabyte source header; force an owned copy. */ +function ownString(value: string): string { + return Buffer.from(value, 'utf8').toString('utf8'); +} + /** * Collect function-like macros from (comment-stripped) source, joining * `\`-continuations first. Only object/positional table macros matter here, so @@ -191,9 +197,9 @@ function parseFunctionMacros(stripped: string): Map { const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)\s+(.+)$/gm; let m: RegExpExecArray | null; while ((m = RE.exec(joined))) { - const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean); + const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean).map(ownString); if (params.some((p) => p === '...' || p.endsWith('...'))) continue; // variadic — skip - out.set(m[1]!, { params, expansion: m[3]!.trim() }); + out.set(ownString(m[1]!), { params, expansion: ownString(m[3]!.trim()) }); } return out; } @@ -207,22 +213,41 @@ function parseObjectMacros(stripped: string): Map { const out = new Map(); if (!stripped.includes('#define') && !stripped.includes('# define')) return out; const joined = stripped.replace(/\\\r?\n/g, ' '); - const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(\S[^\n]*)$/gm; + const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm; let m: RegExpExecArray | null; - while ((m = RE.exec(joined))) out.set(m[1]!, m[2]!.trim()); + while ((m = RE.exec(joined))) out.set(ownString(m[1]!), ownString(m[2]!)); return out; } /** All macro names a file `#define`s (value-ful or not) — the "defined" set for #ifdef. */ -function parseDefinedNames(stripped: string): Set { +function parseDefinedNames(stripped: string, relevant: Set): Set { const out = new Set(); - if (!stripped.includes('#define') && !stripped.includes('# define')) return out; + if (relevant.size === 0 || (!stripped.includes('#define') && !stripped.includes('# define'))) return out; const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)/gm; let m: RegExpExecArray | null; - while ((m = RE.exec(stripped))) out.add(m[1]!); + while ((m = RE.exec(stripped))) { + if (relevant.has(m[1]!)) out.add(ownString(m[1]!)); + } return out; } +/** Names the conditional evaluator can observe. Keeping only these in each + * file's defined-set drops millions of numeric register macros that can never + * affect an `#ifdef`/`defined(NAME)` branch in any registration unit. */ +function collectConditionalNames(source: string, out: Set): void { + if (!source.includes('#if') && !source.includes('# if') && !source.includes('#elif')) return; + const ifdef = /^[ \t]*#[ \t]*(?:ifdef|ifndef)[ \t]+(\w+)/gm; + let m: RegExpExecArray | null; + while ((m = ifdef.exec(source))) out.add(ownString(m[1]!)); + const line = /^[ \t]*#[ \t]*(?:if|elif)\b([^\n]*)$/gm; + while ((m = line.exec(source))) { + const expr = m[1]!; + const defined = /\bdefined\s*(?:\(\s*)?(\w+)/g; + let d: RegExpExecArray | null; + while ((d = defined.exec(expr))) out.add(ownString(d[1]!)); + } +} + /** * Drop the inactive arms of `#ifdef`/`#ifndef`/`#if defined(X)`/`#else`/`#elif`/ * `#endif` given a set of defined macro names, keeping line offsets (inactive @@ -370,7 +395,7 @@ const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i; * are excluded: `resolveTypeName` would rewrite to a dead-end token that can * never name a struct, so skipping them is exact, and it drops the register * flood. */ -const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; +const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm; /** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that * has ≥1 fn-pointer field. Handles both single (`= {…}`) and array @@ -469,19 +494,26 @@ export async function cFnPointerDispatchEdges( // The extraction sweep reads sequentially; the linking stages re-request // only surviving files (plus include units), so access is near-sequential // and a small LRU hits; a miss just re-reads + re-strips. - // Cache sizing is memory-budget-aware AND all-or-nothing (§7a.3 cFnPtr - // round): a partial LRU is WORSE than useless for cyclic sweeps (a first - // attempt sized ~61k against 63.8k files thrashed to a ~0% cross-sweep hit - // rate). Hold every stripped file (~24KB each measured on the Linux tree) - // only when 40% of the live memory budget covers it; otherwise keep the - // within-stage-locality 128. When the big cache declines (the kernel), the - // survival filters keep the linking stages' re-strips to a fraction of a - // sweep. Slack over files.length: non-indexed includes (.def/.inc, generated - // headers) join the working set mid-pass. Pass-scoped transient, freed on - // return. + // Bound by ACTUAL retained string bytes, not an average bytes-per-file guess: + // large generated files made the old entry-count estimate understate the + // cache by multiples and drove the main-thread resolution pass into OOM. + // UTF-16 length*2 is conservative for V8's one-byte strings and correctly + // bounds the worst case. The shared 128MB ceiling is also capped at 5% of + // currently-available memory, leaving room for the DB, fact tables and edges. + // The survival filters keep cache misses in later stages to a fraction of a + // full sweep, so bounded re-reads are preferable to an unbounded peak. const fullCacheCap = Math.ceil(files.length * 1.05) + 512; - const cacheCap = memoryBudgetBytes() * 0.5 >= fullCacheCap * 24_576 ? fullCacheCap : 128; - const rawCache = new LRUCache(Math.min(cacheCap, 4096)); + const totalTextBudget = Math.max( + 16 * 1024 * 1024, + Math.min(128 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.05)) + ); + const rawBudget = Math.floor(totalTextBudget / 3); + const srcBudget = totalTextBudget - rawBudget; + const stringWeight = (value: string | null): number => value === null ? 1 : Math.max(64, value.length * 2); + const rawCache = new LRUCache(Math.min(fullCacheCap, 4096), { + maxWeight: rawBudget, + weightOf: stringWeight, + }); const raw = (file: string): string | null => { if (rawCache.has(file)) return rawCache.get(file)!; const t0 = prof ? Date.now() : 0; @@ -490,7 +522,10 @@ export async function cFnPointerDispatchEdges( rawCache.set(file, r); return r; }; - const srcCache = new LRUCache(cacheCap); + const srcCache = new LRUCache(fullCacheCap, { + maxWeight: srcBudget, + weightOf: stringWeight, + }); const src = (file: string): string | null => { // A cached '' (empty or unreadable file) returns '' where the miss path // returns null for unreadable — every caller falsy-checks, so the two are @@ -548,6 +583,8 @@ export async function cFnPointerDispatchEdges( const inlineTags = new Set(); /** Object-macro names with an alias-shaped value anywhere (see OBJ_ALIAS_RE). */ const aliasNames = new Set(); + /** Macro names that can affect a supported preprocessor conditional anywhere. */ + const conditionalNames = new Set(); // Parse a struct body (the text between its `{` and `}`) into ordered fields, // structure only — see RawFieldDecl for why classification is deferred. @@ -698,6 +735,7 @@ export async function cFnPointerDispatchEdges( await tick(); const rawText = raw(file); if (!rawText) continue; // unreadable or empty — the JS sweep skips these too + collectConditionalNames(rawText, conditionalNames); const tN = prof ? Date.now() : 0; const fileNodes = ctx.getNodesInFile(file); if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } @@ -719,6 +757,7 @@ export async function cFnPointerDispatchEdges( await tick(); const s = src(file); if (!s) continue; + collectConditionalNames(s, conditionalNames); // Typedefs (cross-file). if (s.includes('typedef')) { @@ -991,22 +1030,50 @@ export async function cFnPointerDispatchEdges( // parsed tables is ruled out by the kernel's 6.1M `#define`s, and the // registration stage below only builds an env for files that survive its // filter or carry local includes, so most files never need one. - const fnMacroCache = new LRUCache>(256); + const macroCacheBudget = Math.min(64 * 1024 * 1024, Math.max(8 * 1024 * 1024, totalTextBudget / 2)); + const fnMacroWeight = (macros: Map): number => { + let chars = 0; + for (const [name, def] of macros) { + chars += name.length + def.expansion.length; + for (const param of def.params) chars += param.length; + } + return Math.max(64, chars * 2); + }; + const objMacroWeight = (macros: Map): number => { + let chars = 0; + for (const [name, value] of macros) chars += name.length + value.length; + return Math.max(64, chars * 2); + }; + const definedWeight = (names: Set): number => { + let chars = 0; + for (const name of names) chars += name.length; + return Math.max(64, chars * 2); + }; + const fnMacroCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.4), + weightOf: fnMacroWeight, + }); const fileFnMacros = (file: string): Map => { let m = fnMacroCache.get(file); if (!m) { m = parseFunctionMacros(src(file) ?? ''); fnMacroCache.set(file, m); } return m; }; - const objMacroCache = new LRUCache>(256); + const objMacroCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.2), + weightOf: objMacroWeight, + }); const fileObjMacros = (file: string): Map => { let m = objMacroCache.get(file); if (!m) { m = parseObjectMacros(src(file) ?? ''); objMacroCache.set(file, m); } return m; }; - const definedCache = new LRUCache>(256); + const definedCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.4), + weightOf: definedWeight, + }); const fileDefinedNames = (file: string): Set => { let d = definedCache.get(file); - if (!d) { d = parseDefinedNames(src(file) ?? ''); definedCache.set(file, d); } + if (!d) { d = parseDefinedNames(src(file) ?? '', conditionalNames); definedCache.set(file, d); } return d; }; @@ -1204,6 +1271,25 @@ export async function cFnPointerDispatchEdges( processUnit({ text, file: target, env: incEnv, objEnv: incObjEnv }); } } + // Registration-only facts and memo tables are substantial on C-heavy repos; + // release them before propagation/dispatch allocate their own working sets. + for (const facts of factsByFile.values()) { + facts.initTokens = null; + facts.arrayElems = null; + facts.inlineTypes = null; + facts.includes = NO_INCLUDES; + } + inlineTags.clear(); + aliasNames.clear(); + includeCache.clear(); + fnMacroCache.clear(); + objMacroCache.clear(); + definedCache.clear(); + seenInclude.clear(); + interned.clear(); + fnPtrTypedefs.clear(); + fnTypeTypedefs.clear(); + conditionalNames.clear(); if (prof) { prof.C = Date.now() - tPass; tPass = Date.now(); } // ---- receiver-type resolution within a function's source ---- @@ -1321,6 +1407,8 @@ export async function cFnPointerDispatchEdges( } if (!changed) break; } + propagations.length = 0; + for (const facts of factsByFile.values()) facts.dPairs = null; if (prof) { prof.D = Date.now() - tPass; tPass = Date.now(); } if (reg.size === 0 && arrayReg.size === 0) return []; diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 60b389937..6de79bea0 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -3709,7 +3709,17 @@ export async function synthesizeCallbackEdges( // fan out across its read-only workers and the per-pass wall-clock comes from // the worker; a pass that fails on a worker falls back to running on the main // thread, so a worker crash isolates to a retry instead of failing synthesis. - const passEdges: Edge[][] = new Array(SYNTH_PASSES.length).fill(NONE); + const passEdges: Array = new Array(SYNTH_PASSES.length); + const merged: Edge[] = []; + const seen = new Set(); + const mergeEdges = (edges: Edge[]): void => { + for (const e of edges) { + const key = `${e.source}>${e.target}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(e); + } + }; const markPass = (label: string, dt: number): void => { if (process.env.CODEGRAPH_SYNTH_TIMINGS && (dt > 250 || process.env.CODEGRAPH_SYNTH_TIMINGS === 'all')) { console.error(`[synth-timing] ${label}: ${dt}ms`); @@ -3717,10 +3727,12 @@ export async function synthesizeCallbackEdges( passesDone++; emit(passesDone); }; - const runPassOnMain = async (i: number): Promise => { + const runPassOnMain = async (i: number, retainForOrderedMerge: boolean): Promise => { const pass = SYNTH_PASSES[i]!; const t0 = Date.now(); - passEdges[i] = await pass.run(queries, ctx, yieldToLoop, subProgress); + const edges = await pass.run(queries, ctx, yieldToLoop, subProgress); + if (retainForOrderedMerge) passEdges[i] = edges; + else mergeEdges(edges); await yieldToLoop(); markPass(pass.name, Date.now() - t0); }; @@ -3760,23 +3772,25 @@ export async function synthesizeCallbackEdges( } // Worker-side failure (crash, OOM, unknown pass after a version // mismatch): retry this one pass on the main thread. - await runPassOnMain(i); + await runPassOnMain(i, true); } }) ); } else { for (const i of gatedIn) { - await runPassOnMain(i); + // Merge before starting the next pass so the just-produced array can be + // reclaimed instead of retaining every pass result until the end. + await runPassOnMain(i, false); } } - const merged: Edge[] = []; - const seen = new Set(); - for (const e of passEdges.flat()) { - const key = `${e.source}>${e.target}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(e); + markT.t = Date.now(); + if (pool && gatedIn.length > 1) { + // Worker results arrive out of order, so merge them in registry order to + // preserve which duplicate edge wins while releasing each array promptly. + for (const edges of passEdges) { + if (edges) mergeEdges(edges); + } } __mark('dedupe-merge'); // Chunked insert with yields: on the Linux kernel the merged synthesized diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 01f615b28..aac8077c8 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -28,6 +28,7 @@ import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packa import { logDebug } from '../errors'; import type { ReExport } from './types'; import { LRUCache } from './lru-cache'; +import { memoryBudgetBytes } from './memory-budget'; /** Node kinds that can declare supertypes (extends/implements). */ const SUPERTYPE_BEARING_KINDS = new Set([ @@ -288,8 +289,15 @@ export class ReferenceResolver { // The content cache is heavier (full file text), so we give it a // smaller budget than the metadata caches. const contentLimit = Math.max(64, Math.floor(limit / 5)); + const contentBudget = Math.max( + 8 * 1024 * 1024, + Math.min(64 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.02)) + ); this.nodeCache = new LRUCache(limit); - this.fileCache = new LRUCache(contentLimit); + this.fileCache = new LRUCache(contentLimit, { + maxWeight: Math.floor(contentBudget / 4), + weightOf: (value) => value === null ? 1 : Math.max(64, value.length * 2), + }); this.importMappingCache = new LRUCache(limit); this.reExportCache = new LRUCache(limit); this.nameCache = new LRUCache(limit); @@ -297,7 +305,12 @@ export class ReferenceResolver { this.qualifiedNameCache = new LRUCache(limit); // Split-lines arrays are heavier than content strings; refs arrive // file-ordered, so a small cache still hits nearly always. - this.fileLinesCache = new LRUCache(contentLimit); + this.fileLinesCache = new LRUCache(contentLimit, { + maxWeight: Math.floor(contentBudget * 3 / 4), + weightOf: (value) => value === null + ? 1 + : Math.max(64, value.length * 8 + value.reduce((sum, line) => sum + line.length * 2, 0)), + }); this.methodMatchCache = new LRUCache(limit); this.context = this.createContext(); diff --git a/src/resolution/lru-cache.ts b/src/resolution/lru-cache.ts index 2a597ddbe..fdc3f7e9d 100644 --- a/src/resolution/lru-cache.ts +++ b/src/resolution/lru-cache.ts @@ -13,13 +13,25 @@ */ export class LRUCache { private readonly max: number; + private readonly maxWeight: number | null; + private readonly weightOf: ((value: V, key: K) => number) | null; private readonly store = new Map(); + private readonly weights = new Map(); + private totalWeight = 0; - constructor(max: number) { + constructor( + max: number, + opts: { maxWeight: number; weightOf: (value: V, key: K) => number } | null = null + ) { if (!Number.isFinite(max) || max <= 0) { throw new Error(`LRUCache max must be a positive finite number, got ${max}`); } + if (opts && (!Number.isFinite(opts.maxWeight) || opts.maxWeight <= 0)) { + throw new Error(`LRUCache maxWeight must be a positive finite number, got ${opts.maxWeight}`); + } this.max = Math.floor(max); + this.maxWeight = opts ? Math.floor(opts.maxWeight) : null; + this.weightOf = opts?.weightOf ?? null; } get size(): number { @@ -45,18 +57,35 @@ export class LRUCache { set(key: K, value: V): void { if (this.store.has(key)) { + this.totalWeight -= this.weights.get(key) ?? 0; this.store.delete(key); - } else if (this.store.size >= this.max) { + this.weights.delete(key); + } + + const weight = this.weightOf ? Math.max(0, Math.ceil(this.weightOf(value, key))) : 0; + if (this.maxWeight !== null && weight > this.maxWeight) return; + + while ( + this.store.size >= this.max || + (this.maxWeight !== null && this.totalWeight + weight > this.maxWeight) + ) { // Evict the oldest entry — first key in iteration order. const oldest = this.store.keys().next().value; - if (oldest !== undefined) { - this.store.delete(oldest); - } + if (oldest === undefined) break; + this.totalWeight -= this.weights.get(oldest) ?? 0; + this.weights.delete(oldest); + this.store.delete(oldest); } this.store.set(key, value); + if (this.maxWeight !== null) { + this.weights.set(key, weight); + this.totalWeight += weight; + } } clear(): void { this.store.clear(); + this.weights.clear(); + this.totalWeight = 0; } } From e92fb8e9bbc477cb0d10721308469841006d2763 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:04:56 +0300 Subject: [PATCH 2/8] fix(mcp): recycle idle query workers --- __tests__/query-pool.test.ts | 31 +++++++++++++++++++++++ src/mcp/query-pool.ts | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index d7bf3be91..283e2c927 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -116,6 +116,37 @@ describe('QueryPool', () => { await pool.destroy(); }); + it('recycles an idle burst back to one fresh worker', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const workers: FakeWorker[] = []; + const pool = new QueryPool({ + root: '/x', size: 4, idleShrinkMs: 20, + createWorker: () => { + const worker = new FakeWorker((m) => ({ + wait: gate.then(() => ok(`r${m.id}`)), + })); + workers.push(worker); + return worker; + }, + }); + + const calls = Promise.all(Array.from({ length: 4 }, (_, i) => pool.run('codegraph_search', { i }))); + await sleep(40); + expect(pool.liveWorkers).toBe(4); + release(); + await calls; + await sleep(40); + + expect(pool.liveWorkers).toBe(1); + expect(workers).toHaveLength(5); // four used isolates replaced by one clean isolate + expect(workers.slice(0, 4).every((w) => !w.alive)).toBe(true); + expect(pool.ready).toBe(true); + const again = await pool.run('codegraph_node', { symbol: 's' }); + expect(again.isError).toBeFalsy(); + await pool.destroy(); + }); + it('recovers from a worker crash: retries the in-flight call and respawns', async () => { let calls = 0; const pool = new QueryPool({ diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 0575def38..b9d36f8e4 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -48,6 +48,10 @@ export interface PoolWorker { /** Default linger before a queued call is answered with busy-guidance. */ const DEFAULT_BUSY_TIMEOUT_MS = 45_000; // < the ~60s MCP client request timeout +/** Recycle a quiescent pool after a burst so worker-local graph/SQLite caches + * do not remain resident for the daemon's whole lifetime. */ +const DEFAULT_IDLE_SHRINK_MS = 60_000; + /** Hard ceiling on pool size regardless of core count / env. */ const MAX_POOL_SIZE = 16; @@ -97,6 +101,8 @@ export interface QueryPoolOptions { softTimeoutMs?: number; /** Retries for an in-flight call whose worker crashed. Default 1. */ maxRetries?: number; + /** Idle delay before recycling back to one fresh worker. Default 60s; 0 disables. */ + idleShrinkMs?: number; /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */ createWorker?: () => PoolWorker; } @@ -157,13 +163,16 @@ export class QueryPool { private readonly maxSize: number; private readonly softTimeoutMs: number; private readonly maxRetries: number; + private readonly idleShrinkMs: number; private readonly createWorker: () => PoolWorker; + private idleShrinkTimer?: NodeJS.Timeout; constructor(opts: QueryPoolOptions) { this.root = opts.root; this.maxSize = Math.max(1, Math.min(opts.size ?? Math.max(1, os.cpus().length - 1), MAX_POOL_SIZE)); this.softTimeoutMs = opts.softTimeoutMs ?? resolveBusyTimeoutMs(); this.maxRetries = opts.maxRetries ?? 1; + this.idleShrinkMs = Math.max(0, opts.idleShrinkMs ?? DEFAULT_IDLE_SHRINK_MS); this.createWorker = opts.createWorker ?? (() => new Worker(WORKER_FILE, { workerData: { root: this.root } })); this.spawnOne(); // one eager warm worker, ready for the first call } @@ -232,7 +241,45 @@ export class QueryPool { this.idle.push(w); if (job) this.settle(job, m.result ?? busyGuidance(0)); this.drain(); + this.armIdleShrink(); + } + } + + private clearIdleShrink(): void { + if (this.idleShrinkTimer) clearTimeout(this.idleShrinkTimer); + this.idleShrinkTimer = undefined; + } + + private armIdleShrink(): void { + this.clearIdleShrink(); + if ( + this.idleShrinkMs === 0 || this.destroyed || this.queue.length > 0 || + this.inflight.size > 0 || this.pendingWorkers.size > 0 || + this.idle.length !== this.workers.size + ) return; + this.idleShrinkTimer = setTimeout(() => this.shrinkIdle(), this.idleShrinkMs); + this.idleShrinkTimer.unref?.(); + } + + private shrinkIdle(): void { + this.idleShrinkTimer = undefined; + if ( + this.destroyed || this.queue.length > 0 || this.inflight.size > 0 || + this.pendingWorkers.size > 0 || this.idle.length !== this.workers.size + ) return; + + // Drop every used isolate, including the last one: a single worker can hold + // hundreds of MB in graph and SQLite caches after a large query. Replace it + // with one clean warm worker so the next burst still avoids a cold queue. + const stale = [...this.workers]; + this.workers.clear(); + this.idle = []; + this.everReady = false; + for (const w of stale) { + try { void Promise.resolve(w.terminate()).catch(() => { /* already gone */ }); } + catch { /* already gone */ } } + this.spawnOne(); } // A worker died (crash hook, OOM, segfault, exit≠0). Respawn a replacement and @@ -291,6 +338,7 @@ export class QueryPool { /** Run a read tool on the pool. Always resolves (never rejects). */ run(toolName: string, args: Record): Promise { + this.clearIdleShrink(); return new Promise((resolve) => { const job: Job = { id: this.nextId++, toolName, args, resolve, @@ -312,6 +360,7 @@ export class QueryPool { async destroy(): Promise { if (this.destroyed) return; this.destroyed = true; + this.clearIdleShrink(); const ws = [...this.workers]; this.workers.clear(); this.pendingWorkers.clear(); From fa8d8cc74e5e3832904acea57e6bf0ce086aa079 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:07:17 +0300 Subject: [PATCH 3/8] fix(graph): make shortest-path search linear --- __tests__/graph.test.ts | 31 ++++++++++++++++++++++ src/graph/traversal.ts | 57 ++++++++++++++++++++++------------------- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 5379c97c3..c56376d73 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -535,6 +535,37 @@ function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser { } describe('Traversal edge-completeness & limits (#1086–#1090)', () => { + it('findPath keeps shortest-path order without duplicate frontier entries', () => { + const nodes = ['A', 'B', 'C', 'D', 'E'].map((id) => tNode(id)); + const edges: Edge[] = [ + { source: 'A', target: 'B', kind: 'calls', line: 1 }, + { source: 'A', target: 'B', kind: 'references', line: 2 }, + { source: 'A', target: 'C', kind: 'calls', line: 3 }, + { source: 'B', target: 'D', kind: 'calls', line: 4 }, + { source: 'C', target: 'D', kind: 'calls', line: 5 }, + { source: 'D', target: 'E', kind: 'calls', line: 6 }, + ]; + const byId = new Map(nodes.map((n) => [n.id, n])); + const batches: string[][] = []; + const q = { + getNodeById: (id: string) => byId.get(id) ?? null, + getNodesByIds: (ids: readonly string[]) => { + batches.push([...ids]); + expect(new Set(ids).size).toBe(ids.length); + return new Map(ids.flatMap((id) => { + const node = byId.get(id); + return node ? [[id, node] as const] : []; + })); + }, + getOutgoingEdges: (source: string) => edges.filter((e) => e.source === source), + }; + + const path = new GraphTraverser(q as never).findPath('A', 'E'); + expect(path?.map((step) => step.node.id)).toEqual(['A', 'B', 'D', 'E']); + expect(path?.map((step) => step.edge?.line ?? null)).toEqual([null, 1, 4, 6]); + expect(batches[0]).toEqual(['B', 'C']); + }); + it('traverseBFS keeps every parallel edge to the same target (#1090)', () => { // A reaches B via both `calls` and `references` — two distinct edges. const edges: Edge[] = [ diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 5e9b354e9..bc2bfe187 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -626,24 +626,31 @@ export class GraphTraverser { return null; } - // BFS to find shortest path - const visited = new Set(); - const queue: Array<{ nodeId: string; path: Array<{ node: Node; edge: Edge | null }> }> = [ - { nodeId: fromId, path: [{ node: fromNode, edge: null }] }, - ]; - - while (queue.length > 0) { - const { nodeId, path } = queue.shift()!; + // BFS to find the shortest path. Keep one predecessor per discovered node + // instead of copying the full path into every queue entry; use a head index + // so dequeues stay O(1), and mark on enqueue so converging edges cannot + // multiply queued work. + const enqueued = new Set([fromId]); + const parents = new Map(); + const queue: string[] = [fromId]; + let head = 0; + + while (head < queue.length) { + const nodeId = queue[head++]!; if (nodeId === toId) { + const path: Array<{ node: Node; edge: Edge | null }> = []; + let currentId = toId; + while (currentId !== fromId) { + const step = parents.get(currentId)!; + path.push({ node: step.node, edge: step.edge }); + currentId = step.parentId; + } + path.push({ node: fromNode, edge: null }); + path.reverse(); return path; } - if (visited.has(nodeId)) { - continue; - } - visited.add(nodeId); - // Get outgoing edges const outgoingEdges = this.queries.getOutgoingEdges( nodeId, @@ -651,22 +658,20 @@ export class GraphTraverser { ); if (outgoingEdges.length === 0) continue; - // Batch-fetch only the unvisited targets (was N+1 per BFS frontier). - const wantIds = outgoingEdges - .map((e) => e.target) - .filter((id) => !visited.has(id)); + // Batch-fetch only undiscovered targets, once each even when parallel + // edges point at the same node. + const wantIds = [...new Set( + outgoingEdges.map((e) => e.target).filter((id) => !enqueued.has(id)) + )]; const nextNodes = wantIds.length > 0 ? this.queries.getNodesByIds(wantIds) : new Map(); for (const edge of outgoingEdges) { - if (!visited.has(edge.target)) { - const nextNode = nextNodes.get(edge.target); - if (nextNode) { - queue.push({ - nodeId: edge.target, - path: [...path, { node: nextNode, edge }], - }); - } - } + if (enqueued.has(edge.target)) continue; + const nextNode = nextNodes.get(edge.target); + if (!nextNode) continue; + enqueued.add(edge.target); + parents.set(edge.target, { parentId: nodeId, node: nextNode, edge }); + queue.push(edge.target); } } From 7e3823eefae96d795034e77c40b07f7c61c2aba0 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:11:04 +0300 Subject: [PATCH 4/8] fix(graph): bound impact traversal --- __tests__/graph.test.ts | 24 +++++++++++++++++++++ src/db/queries.ts | 26 +++++++++++++++++------ src/graph/traversal.ts | 47 ++++++++++++++++++++++++++++++++++------- src/index.ts | 5 +++-- src/mcp/tools.ts | 25 +++++++++++++++++++--- src/types.ts | 11 ++++++++++ 6 files changed, 119 insertions(+), 19 deletions(-) diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index c56376d73..fa83768ff 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -11,6 +11,7 @@ import * as os from 'os'; import CodeGraph from '../src/index'; import { Node, Edge } from '../src/types'; import { GraphTraverser } from '../src/graph/traversal'; +import { ToolHandler } from '../src/mcp/tools'; describe('Graph Queries', () => { let testDir: string; @@ -643,4 +644,27 @@ describe('Traversal edge-completeness & limits (#1086–#1090)', () => { // The regression: this direct dependency edge used to vanish. expect(sub.edges.some((e) => e.source === 'Q' && e.target === 'P' && e.kind === 'calls')).toBe(true); }); + + it('getImpactRadius stops at node/edge budgets and marks truncation', () => { + const dependents = ['B', 'C', 'D', 'E', 'F']; + const nodes = [tNode('A'), ...dependents.map((id) => tNode(id))]; + const edges: Edge[] = dependents.map((source) => ({ source, target: 'A', kind: 'calls' })); + const sub = tGraph(nodes, edges).getImpactRadius('A', 2, { maxNodes: 3, maxEdges: 2 }); + + expect(sub.nodes.size).toBe(3); + expect(sub.edges).toHaveLength(2); + expect(sub.truncated).toBe(true); + expect(sub.edges.every((edge) => sub.nodes.has(edge.source) && sub.nodes.has(edge.target))).toBe(true); + }); + + it('surfaces impact truncation explicitly in MCP output', () => { + const formatted = (new ToolHandler(null) as any).formatImpact('A', { + nodes: new Map([['A', tNode('A')]]), + edges: [], + roots: ['A'], + truncated: true, + }); + expect(formatted).toMatch(/truncated at safety limit/i); + expect(formatted).toMatch(/reduce `depth`/i); + }); }); diff --git a/src/db/queries.ts b/src/db/queries.ts index 2b8bc5344..1c7ff42ee 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1741,8 +1741,8 @@ export class QueryBuilder { /** * Get outgoing edges from a node */ - getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] { - if ((kinds && kinds.length > 0) || provenance) { + getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string, limit?: number): Edge[] { + if ((kinds && kinds.length > 0) || provenance || limit !== undefined) { let sql = 'SELECT * FROM edges WHERE source = ?'; const params: (string | number)[] = [sourceId]; @@ -1756,6 +1756,11 @@ export class QueryBuilder { params.push(provenance); } + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } @@ -1770,10 +1775,19 @@ export class QueryBuilder { /** * Get incoming edges to a node */ - getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { - if (kinds && kinds.length > 0) { - const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')})`; - const rows = this.db.prepare(sql).all(targetId, ...kinds) as EdgeRow[]; + getIncomingEdges(targetId: string, kinds?: EdgeKind[], limit?: number): Edge[] { + if ((kinds && kinds.length > 0) || limit !== undefined) { + let sql = 'SELECT * FROM edges WHERE target = ?'; + const params: (string | number)[] = [targetId]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } + const rows = this.db.prepare(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index bc2bfe187..a6eb66364 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -4,7 +4,7 @@ * BFS and DFS traversal for the code knowledge graph. */ -import { Node, Edge, Subgraph, TraversalOptions, EdgeKind } from '../types'; +import { Node, Edge, Subgraph, TraversalOptions, EdgeKind, ImpactOptions, EDGE_KINDS } from '../types'; import { QueryBuilder } from '../db/queries'; /** @@ -19,6 +19,10 @@ const DEFAULT_OPTIONS: Required = { includeStart: true, }; +const DEFAULT_IMPACT_MAX_NODES = 10_000; +const DEFAULT_IMPACT_MAX_EDGES = 50_000; +const IMPACT_INCOMING_KINDS = EDGE_KINDS.filter((kind) => kind !== 'contains'); + /** * Result of a single traversal step */ @@ -517,7 +521,7 @@ export class GraphTraverser { * @param maxDepth - Maximum depth to traverse (default: 3) * @returns Subgraph containing potentially impacted nodes */ - getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph { + getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph { const focalNode = this.queries.getNodeById(nodeId); if (!focalNode) { return { nodes: new Map(), edges: [], roots: [] }; @@ -526,17 +530,23 @@ export class GraphTraverser { const nodes = new Map(); const edges: Edge[] = []; const visited = new Set(); + const budget = { + maxNodes: Math.max(1, Math.floor(options.maxNodes ?? DEFAULT_IMPACT_MAX_NODES)), + maxEdges: Math.max(0, Math.floor(options.maxEdges ?? DEFAULT_IMPACT_MAX_EDGES)), + truncated: false, + }; // Add focal node nodes.set(focalNode.id, focalNode); // Traverse incoming edges to find all dependents - this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited); + this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, budget); return { nodes, edges, roots: [nodeId], + truncated: budget.truncated || undefined, }; } @@ -546,7 +556,8 @@ export class GraphTraverser { currentDepth: number, nodes: Map, edges: Edge[], - visited: Set + visited: Set, + budget: { maxNodes: number; maxEdges: number; truncated: boolean } ): void { // Mark visited before the depth check so a node collected at the depth // boundary still lands in `visited`. Otherwise it could sit in `nodes` but @@ -566,16 +577,23 @@ export class GraphTraverser { if (focalNode) { const containerKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'module', 'enum']); if (containerKinds.has(focalNode.kind)) { - const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']); + const remaining = budget.maxEdges - edges.length; + const fetched = this.queries.getOutgoingEdges(nodeId, ['contains'], undefined, remaining + 1); + if (fetched.length > remaining) budget.truncated = true; + const containsEdges = fetched.slice(0, remaining); if (containsEdges.length > 0) { const children = this.queries.getNodesByIds(containsEdges.map((e) => e.target)); for (const edge of containsEdges) { const childNode = children.get(edge.target); if (childNode && !visited.has(childNode.id)) { + if (nodes.size >= budget.maxNodes || edges.length >= budget.maxEdges) { + budget.truncated = true; + continue; + } nodes.set(childNode.id, childNode); edges.push(edge); // Recurse into children at the same depth (they're part of the same symbol) - this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited); + this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, budget); } } } @@ -586,7 +604,12 @@ export class GraphTraverser { // `contains`: a container "contains" its members but does not *depend* on // them, so following it upward would climb to the parent class and then // re-expand every sibling member — exploding impact for a leaf symbol. (#536) - const incomingEdges = this.queries.getIncomingEdges(nodeId).filter((e) => e.kind !== 'contains'); + const remaining = budget.maxEdges - edges.length; + // Fetch one extra row so hitting the SQL LIMIT is distinguishable from an + // exact-size result and can be surfaced as explicit truncation. + const fetched = this.queries.getIncomingEdges(nodeId, IMPACT_INCOMING_KINDS, remaining + 1); + if (fetched.length > remaining) budget.truncated = true; + const incomingEdges = fetched.slice(0, remaining); if (incomingEdges.length === 0) return; const sources = this.queries.getNodesByIds(incomingEdges.map((e) => e.source)); @@ -598,10 +621,18 @@ export class GraphTraverser { // node already collected via another path was silently dropped from // `edges` even though it's a real dependency (#1089). Each node's incoming // edges are fetched once (nodes are expanded once), so no edge repeats. + if (!nodes.has(sourceNode.id) && nodes.size >= budget.maxNodes) { + budget.truncated = true; + continue; + } + if (edges.length >= budget.maxEdges) { + budget.truncated = true; + continue; + } edges.push(edge); if (!visited.has(sourceNode.id)) { nodes.set(sourceNode.id, sourceNode); - this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited); + this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, budget); } } } diff --git a/src/index.ts b/src/index.ts index 2942575b5..6a1e18fbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { TaskContext, BuildContextOptions, FindRelevantContextOptions, + ImpactOptions, } from './types'; import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db'; import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve'; @@ -1705,8 +1706,8 @@ export class CodeGraph { * @param maxDepth - Maximum depth to traverse (default: 3) * @returns Subgraph containing potentially impacted nodes */ - getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph { - return this.traverser.getImpactRadius(nodeId, maxDepth); + getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph { + return this.traverser.getImpactRadius(nodeId, maxDepth, options); } /** diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4ad7e64b6..482766ec1 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2364,23 +2364,39 @@ export class ToolHandler { : ''; const impactOf = (defNodes: Node[]) => { + const maxNodes = 1_000; + const maxEdges = 5_000; const mergedNodes = new Map(); const mergedEdges: Edge[] = []; const seenEdges = new Set(); + let truncated = false; for (const node of defNodes) { - const impact = cg.getImpactRadius(node.id, depth); + const impact = cg.getImpactRadius(node.id, depth, { maxNodes, maxEdges }); + truncated ||= impact.truncated === true; for (const [id, n] of impact.nodes) { + if (!mergedNodes.has(id) && mergedNodes.size >= maxNodes) { + truncated = true; + continue; + } mergedNodes.set(id, n); } for (const e of impact.edges) { + if (!mergedNodes.has(e.source) || !mergedNodes.has(e.target)) { + truncated = true; + continue; + } const key = `${e.source}->${e.target}:${e.kind}`; if (!seenEdges.has(key)) { + if (mergedEdges.length >= maxEdges) { + truncated = true; + continue; + } seenEdges.add(key); mergedEdges.push(e); } } } - return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) }; + return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id), truncated }; }; // Single definition (or same-file overloads): the familiar merged report. @@ -6918,9 +6934,12 @@ export class ToolHandler { // Compact format: just list affected symbols grouped by file const lines: string[] = [ - `**Impact: "${symbol}" affects ${nodeCount} symbols**`, + `**Impact: "${symbol}" affects ${nodeCount} symbols${impact.truncated ? ' (truncated at safety limit)' : ''}**`, '', ]; + if (impact.truncated) { + lines.push('> Result truncated to protect the MCP process on a high-fanout graph. Narrow with `file` or reduce `depth`.', ''); + } // Group by file const byFile = new Map(); diff --git a/src/types.ts b/src/types.ts index 186f57adc..abbd2cd17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -399,6 +399,17 @@ export interface Subgraph { * for graph traversals that don't run the search-ranking path. */ confidence?: 'high' | 'low'; + + /** True when a traversal stopped at its node/edge safety budget. */ + truncated?: boolean; +} + +/** Safety budgets for impact traversal on high-fanout graphs. */ +export interface ImpactOptions { + /** Maximum nodes retained, including the focal node. Default: 10,000. */ + maxNodes?: number; + /** Maximum edges retained. Default: 50,000. */ + maxEdges?: number; } /** From fddc7d64bd1c79b00453d5524ad07583e29e7692 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:12:15 +0300 Subject: [PATCH 5/8] fix(cfml): release parser trees --- __tests__/extraction.test.ts | 20 ++++++++++++++++++-- src/extraction/cfml-extractor.ts | 10 ++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 292658822..c1bbc36e3 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4,13 +4,13 @@ * Tests for the tree-sitter extraction system. */ -import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { extractFromSource, scanDirectory, buildDefaultIgnore, discoverEmbeddedRepoRoots, buildScopeIgnore } from '../src/extraction'; -import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile } from '../src/extraction/grammars'; +import { detectLanguage, isLanguageSupported, getSupportedLanguages, initGrammars, loadAllGrammars, isSourceFile, getParser } from '../src/extraction/grammars'; import { stripCppTemplateArgs, blankCppExportMacros, blankCppInlineMacros, blankMetalAttributes, blankCudaConstructs, blankCppAnnotationMacroCalls, blankCppApiPrefixMacros, blankCppInlineAnnotationMacros, blankCLeadingAttrMacros, recoverMangledCppName } from '../src/extraction/languages/c-cpp'; import { normalizePath } from '../src/utils'; @@ -9469,6 +9469,22 @@ import foo.cfm; `; + it('releases the tag parser tree after extraction', () => { + const parser = getParser('cfml'); + expect(parser).toBeDefined(); + const sample = parser!.parse(''); + expect(sample).toBeDefined(); + const treePrototype = Object.getPrototypeOf(sample!); + sample!.delete(); + const deleteSpy = vi.spyOn(treePrototype, 'delete'); + try { + extractFromSource('TagStyle.cfc', ''); + expect(deleteSpy).toHaveBeenCalledTimes(1); + } finally { + deleteSpy.mockRestore(); + } + }); + it('should name the component from the file name when the tag has no name attribute', () => { const result = extractFromSource('TagStyle.cfc', code); const cls = result.nodes.find((n) => n.kind === 'class'); diff --git a/src/extraction/cfml-extractor.ts b/src/extraction/cfml-extractor.ts index 2f4bc4779..c873443bc 100644 --- a/src/extraction/cfml-extractor.ts +++ b/src/extraction/cfml-extractor.ts @@ -112,8 +112,14 @@ export class CfmlExtractor { return; } - const fileNode = this.createFileNode(); - this.walkProgram(tree.rootNode, fileNode.id); + try { + const fileNode = this.createFileNode(); + this.walkProgram(tree.rootNode, fileNode.id); + } finally { + // Tree-sitter trees own WASM/native memory outside V8's heap. Tag-based + // CFML bypasses TreeSitterExtractor, so it must release its tree here. + tree.delete(); + } } /** Build the file's own `kind:'file'` node, spanning the whole source. Tag-based files need this explicitly — unlike `extractBareScript` (which delegates the whole file to `TreeSitterExtractor` and inherits its file node), `extractTagBased` walks the tree itself and has no other source of one. */ From 16a74c59d11d510ea9c98e16526205d151e06715 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:13:56 +0300 Subject: [PATCH 6/8] fix(watch): prune removed directory watches --- __tests__/watcher.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/sync/watcher.ts | 27 ++++++++++++++++++++------- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/__tests__/watcher.test.ts b/__tests__/watcher.test.ts index f493a96cc..5b2d861f1 100644 --- a/__tests__/watcher.test.ts +++ b/__tests__/watcher.test.ts @@ -804,6 +804,42 @@ describe('FileWatcher', () => { expect(calls[0]).toBeUndefined(); }); + it.skipIf(process.platform !== 'linux')('closes descendant watches when a watched directory is removed', async () => { + const calls: (string[] | undefined)[] = []; + const callbacks = new Map void>(); + const closeCounts = new Map(); + __setFsWatchForTests(((dir: fs.PathLike, _opts: unknown, cb: (event: string, filename: string | Buffer | null) => void) => { + const key = String(dir); + callbacks.set(key, cb); + const watcher = new EventEmitter() as fs.FSWatcher; + watcher.close = () => closeCounts.set(key, (closeCounts.get(key) ?? 0) + 1); + return watcher; + }) as typeof fs.watch); + const watcher = new FileWatcher( + testDir, + async (paths?: string[]) => { + calls.push(paths); + return { filesChanged: 1, durationMs: 1 }; + }, + { debounceMs: 30 } + ); + + const nestedDir = path.join(testDir, 'src', 'nested'); + fs.mkdirSync(nestedDir); + expect(watcher.start()).toBe(true); + const srcDir = path.join(testDir, 'src'); + expect(callbacks.has(srcDir)).toBe(true); + expect(callbacks.has(nestedDir)).toBe(true); + fs.rmSync(srcDir, { recursive: true }); + callbacks.get(testDir)!('rename', 'src'); + await new Promise((r) => setTimeout(r, 500)); + + expect(closeCounts.get(srcDir)).toBe(1); + expect(closeCounts.get(nestedDir)).toBe(1); + expect(calls[0]).toBeUndefined(); + watcher.stop(); + }); + it('a lone file event fires on the quick window, well before the full debounce', async () => { const calls: (string[] | undefined)[] = []; const syncFn: SyncFn = async (paths?: string[]) => { diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts index fed6ea608..5cfeb3f1e 100644 --- a/src/sync/watcher.ts +++ b/src/sync/watcher.ts @@ -514,7 +514,8 @@ export class FileWatcher { if (isInotifyWatchExhaustion(err)) { this.warnInotifyLimit({ error: String(err), dir }); } - this.unwatchDir(dir); + this.unwatchSubtree(dir); + this.maybeScheduleForRemovedDir(normalizePath(path.relative(this.projectRoot, dir))); }); this.dirWatchers.set(dir, w); @@ -554,7 +555,15 @@ export class FileWatcher { return; } } catch { - // deleted/inaccessible — treat as a normal change below + // If this path used to own a watch, it was a directory. Linux often emits + // only the parent's rename event and no watcher error for the removed + // subtree, so close every descendant watch here and force a scan-diff. + if (this.unwatchSubtree(full) > 0) { + this.needsFullScan = true; + this.scheduleSync(); + return; + } + // Deleted/inaccessible ordinary file — treat as a normal change below. } this.handleChange(normalizePath(path.relative(this.projectRoot, full))); @@ -621,17 +630,21 @@ export class FileWatcher { this.scheduleSync(); } - /** Close and forget the watch for a directory that errored/was removed. */ - private unwatchDir(dir: string): void { - const w = this.dirWatchers.get(dir); - if (w) { + /** Close and forget a removed directory and every watched descendant. */ + private unwatchSubtree(dir: string): number { + let removed = 0; + const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep; + for (const [watchedDir, w] of [...this.dirWatchers]) { + if (watchedDir !== dir && !watchedDir.startsWith(prefix)) continue; try { w.close(); } catch { /* already closed */ } - this.dirWatchers.delete(dir); + this.dirWatchers.delete(watchedDir); + removed++; } + return removed; } /** Our own dirs are always ignored, regardless of .gitignore. */ From 180c6eacc69ea8e51f172ca18177a4ab57659c2e Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 11:29:18 +0300 Subject: [PATCH 7/8] fix: avoid quadratic synthesis line lookups --- CHANGELOG.md | 1 + __tests__/object-registry-synthesizer.test.ts | 5 ++- __tests__/synthesis-tail-scaling.test.ts | 17 +++++++- src/resolution/callback-synthesizer.ts | 41 ++++++++++++------- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f44be8edd..3fbe0ec34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass. - Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored. - Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names. - Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked. diff --git a/__tests__/object-registry-synthesizer.test.ts b/__tests__/object-registry-synthesizer.test.ts index 9b7ac66f7..7060a5ee3 100644 --- a/__tests__/object-registry-synthesizer.test.ts +++ b/__tests__/object-registry-synthesizer.test.ts @@ -63,7 +63,8 @@ export function direct() { return new table.add().execute(); } const db = (cg as any).db.db; const rows = db .prepare( - `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file + `SELECT s.name source_name, t.name target_name, t.kind target_kind, t.file_path target_file, + e.line edge_line, json_extract(e.metadata,'$.registeredAt') registered_at FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target @@ -77,6 +78,8 @@ export function direct() { return new table.add().execute(); } expect(rows.every((r: any) => r.source_name === 'executeCommand')).toBe(true); expect(rows.every((r: any) => r.target_kind === 'method' && r.target_name === 'execute')).toBe(true); expect(rows.every((r: any) => /commands\.ts$/.test(r.target_file))).toBe(true); + expect(rows.every((r: any) => r.edge_line === 13)).toBe(true); + expect(rows.every((r: any) => /manager\.ts:6$/.test(r.registered_at))).toBe(true); // The statically-accessed look-alike registry contributed nothing. expect(rows.some((r: any) => /static\.ts$/.test(r.target_file))).toBe(false); }); diff --git a/__tests__/synthesis-tail-scaling.test.ts b/__tests__/synthesis-tail-scaling.test.ts index 98729a632..d8276981c 100644 --- a/__tests__/synthesis-tail-scaling.test.ts +++ b/__tests__/synthesis-tail-scaling.test.ts @@ -9,7 +9,7 @@ * SQL-side, and language-gates passes off the files table. * * These tests pin the query-level building blocks and the end-to-end kotlin - * bridge so the memory fix can't silently change what gets synthesized. + * bridge so the memory fixes can't silently change what gets synthesized. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; @@ -100,4 +100,19 @@ class C { expect(langs.has('kotlin')).toBe(false); cg.close(); }); + + it('does not rescan source prefixes to locate every synthesized edge', () => { + const source = fs.readFileSync( + path.resolve('src/resolution/callback-synthesizer.ts'), + 'utf8' + ); + const prefixRescans = source + .split('\n') + .filter((line) => !/^(?:\/\/|\*)/.test(line.trimStart())) + .filter((line) => line.includes('.slice(0,') && line.includes(".split('\\n').length")); + + // Repeating this expression for every regex match makes a match-dense file O(n²). + // Wall-clock thresholds are too noisy for CI, so pin the allocation pattern directly. + expect(prefixRescans).toEqual([]); + }); }); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 6de79bea0..195e364d7 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -609,6 +609,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || !content.includes('emitter.')) continue; const safe = stripCommentsForRegex(content, 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodes = ctx.getNodesInFile(file) .filter((n) => n.kind === 'method' || n.kind === 'function'); @@ -617,7 +618,7 @@ async function arkuiEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P while ((m = ARKUI_EMITTER_CALL_RE.exec(safe))) { const verb = m[1]!; const arg = m[2]!.trim(); - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const encl = nodes .filter((n) => n.startLine <= line && n.endLine >= line) .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; @@ -714,6 +715,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr const content = ctx.readFile(file); if (!content || !content.includes('router.')) continue; const safe = stripCommentsForRegex(content, 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodes = ctx.getNodesInFile(file) .filter((n) => n.kind === 'method' || n.kind === 'function'); @@ -721,7 +723,7 @@ async function arkuiRouterEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr let m: RegExpExecArray | null; while ((m = ARKUI_ROUTER_RE.exec(safe))) { const url = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const encl = nodes .filter((n) => n.startLine <= line && n.endLine >= line) .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; @@ -1961,13 +1963,14 @@ async function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionCon const content = ctx.readFile(file); if (!content || (!content.includes('.Use(') && !/\.(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\(/.test(content))) continue; const safe = stripCommentsForRegex(content, 'go'); + const lineAt = makeLineAt(safe, 1); GIN_REG_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = GIN_REG_RE.exec(safe))) { const parenIdx = m.index + m[0].length - 1; const argStr = goBalancedArgs(safe, parenIdx); if (!argStr) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); for (const arg of goSplitArgs(argStr)) { const name = goHandlerIdent(arg); if (name && !registered.has(name)) registered.set(name, `${file}:${line}`); @@ -2115,6 +2118,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on if (!src) continue; // Thunks are TS/JS-family (same // and /* */ comment syntax); map to a CommentLang. const safe = stripCommentsForRegex(src, node.language === 'javascript' || node.language === 'jsx' ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, node.startLine); THUNK_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; @@ -2139,7 +2143,7 @@ async function reduxThunkEdges(queries: QueryBuilder, ctx: ResolutionContext, on const key = `${node.id}>${target.id}`; if (seen.has(key)) continue; seen.add(key); - const line = node.startLine + safe.slice(0, m.index).split('\n').length - 1; + const line = lineAt(m.index); edges.push({ source: node.id, target: target.id, @@ -2248,6 +2252,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): const newlines = (content.match(/\n/g)?.length ?? 0) + 1; if (content.length / newlines > 200) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); // 1. Dispatch sites: `(new )?[]` followed by a call or a chained method. // A quoted-string key (`['save']`) does NOT match — that's a static access, not dispatch. @@ -2257,7 +2262,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): while ((dm = REGISTRY_DISPATCH_RE.exec(safe))) { const win = safe.slice(dm.index, dm.index + 160); const cm = /\]\s*\([^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win) || /\]\s*\.\s*([A-Za-z_$][\w$]*)/.exec(win); - dispatches.push({ ref: dm[1]!, line: safe.slice(0, dm.index).split('\n').length, chained: cm ? cm[1]! : null }); + dispatches.push({ ref: dm[1]!, line: lineAt(dm.index), chained: cm ? cm[1]! : null }); } if (!dispatches.length) continue; // Normalize a leading `this.` so a class FIELD-INITIALIZER registry (`commands = {…}`) @@ -2276,7 +2281,7 @@ async function objectRegistryEdges(ctx: ResolutionContext, onYield: MaybeYield): if (!body) continue; const names = registryEntryNames(body); // depth-0 `key: Identifier` entries only if (names.length >= REGISTRY_MIN_ENTRIES) { - registries.set(lhs, { names, line: safe.slice(0, am.index).split('\n').length }); + registries.set(lhs, { names, line: lineAt(am.index) }); } } if (!registries.size) continue; @@ -2408,6 +2413,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro const content = ctx.readFile(file); if (!content || !content.includes('Store')) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); // 2. Bind store vars in this file: `const = (...)`. const varStore = new Map(); @@ -2429,7 +2435,7 @@ async function piniaStoreEdges(ctx: ResolutionContext, onYield: MaybeYield): Pro const storeFile = varStore.get(cm[1]!); if (!storeFile) continue; const method = cm[2]!; - const line = safe.slice(0, cm.index).split('\n').length; + const line = lineAt(cm.index); const disp = enclosingFn(nodesInFile, line) ?? fallbackDispatcher; if (!disp) continue; const target = ctx @@ -2514,6 +2520,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || (!content.includes('dispatch(') && !content.includes('commit('))) continue; const safe = stripCommentsForRegex(content, /\.(?:jsx?|mjs|cjs)$/.test(file) ? 'javascript' : 'typescript'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); const fallback = nodesInFile.find((n) => n.kind === 'component'); // .vue top-level VUEX_DISPATCH_RE.lastIndex = 0; @@ -2521,7 +2528,7 @@ async function vuexDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): P let added = 0; while ((m = VUEX_DISPATCH_RE.exec(safe)) && added < VUEX_FANOUT_CAP) { const key = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line) ?? fallback; if (!disp) continue; const target = resolve(key, file); @@ -2611,13 +2618,14 @@ async function celeryDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield): const content = ctx.readFile(file); if (!content || (!content.includes('.delay(') && !content.includes('.apply_async('))) continue; const safe = stripCommentsForRegex(content, 'python'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); CELERY_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; while ((m = CELERY_DISPATCH_RE.exec(safe)) && added < CELERY_FANOUT_CAP) { const name = m[1]!; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; // module-level dispatch — no source symbol to attribute const target = resolve(name, file); @@ -2732,6 +2740,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr const content = ctx.readFile(file); if (!content || !content.includes('.publishEvent(')) continue; const safe = stripCommentsForRegex(content, 'java'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); SPRING_PUBLISH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -2739,7 +2748,7 @@ async function springEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr while ((m = SPRING_PUBLISH_RE.exec(safe)) && added < SPRING_FANOUT_CAP) { const targets = listeners.get(m[1]!); if (!targets || !targets.length) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets) { @@ -2845,6 +2854,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) const content = ctx.readFile(file); if (!content || (!content.includes('.Send(') && !content.includes('.Publish('))) continue; const safe = stripCommentsForRegex(content, 'csharp'); + const lineAt = makeLineAt(safe, 1); const safeLines = safe.split('\n'); const nodesInFile = ctx.getNodesInFile(file); MEDIATR_DISPATCH_RE.lastIndex = 0; @@ -2852,7 +2862,7 @@ async function mediatrDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) let added = 0; while ((m = MEDIATR_DISPATCH_RE.exec(safe)) && added < MEDIATR_FANOUT_CAP) { if (!MEDIATR_RECEIVER_RE.test(m[1]!)) continue; // not a mediator (MessagingCenter, HttpClient, …) - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; const type = resolveMediatrArgType(m[2]!, safeLines, disp.startLine, line); @@ -2943,12 +2953,13 @@ async function sidekiqDispatchEdges(ctx: ResolutionContext, onYield: MaybeYield) const content = ctx.readFile(file); if (!content || !/\.perform_(?:async|in|at)\b/.test(content)) continue; const safe = stripCommentsForRegex(content, 'ruby'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); SIDEKIQ_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; let added = 0; while ((m = SIDEKIQ_DISPATCH_RE.exec(safe)) && added < SIDEKIQ_FANOUT_CAP) { - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; const target = resolve(m[1]!); @@ -3296,6 +3307,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti const content = ctx.readFile(file); if (!content || !/[A-Z][A-Za-z0-9_@]*:[a-z]/.test(content)) continue; const safe = stripCommentsForRegex(content, 'erlang'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); ERLANG_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -3310,7 +3322,7 @@ async function erlangBehaviourDispatchEdges(queries: QueryBuilder, ctx: Resoluti const behaviour = behaviours[0]!; const targets = targetsOf(behaviour, fn); if (targets.length === 0 || targets.length > ERLANG_BEHAVIOUR_FANOUT_CAP) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets) { @@ -3450,6 +3462,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P const content = ctx.readFile(file); if (!content || !content.includes('event(')) continue; const safe = stripCommentsForRegex(content, 'php'); + const lineAt = makeLineAt(safe, 1); const nodesInFile = ctx.getNodesInFile(file); LARAVEL_DISPATCH_RE.lastIndex = 0; let m: RegExpExecArray | null; @@ -3457,7 +3470,7 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P while ((m = LARAVEL_DISPATCH_RE.exec(safe)) && added < LARAVEL_FANOUT_CAP) { const targets = listeners.get(phpSimpleName(m[1]!)); if (!targets) continue; - const line = safe.slice(0, m.index).split('\n').length; + const line = lineAt(m.index); const disp = enclosingFn(nodesInFile, line); if (!disp) continue; for (const target of targets.values()) { From f3fd47e723cc928a1cacf5e9ce7c4ff6bdca9f2f Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 22 Aug 2026 12:56:47 +0300 Subject: [PATCH 8/8] fix(resolution): skip oversized package targets before decoding --- CHANGELOG.md | 1 + __tests__/resolution-file-read.test.ts | 42 ++++++++++++++++++++++++++ src/extraction/index.ts | 18 ++++------- src/file-limits.ts | 6 ++++ src/resolution/index.ts | 9 ++++++ 5 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 __tests__/resolution-file-read.test.ts create mode 100644 src/file-limits.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbe0ec34..f597cfc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Resolution no longer reads oversized dependency archives such as HarmonyOS `.har` packages as source text, preventing a single package target from exhausting the JavaScript heap during indexing or sync. - Dynamic-dispatch analysis no longer repeatedly copies every source prefix while scanning match-dense files, avoiding quadratic work and excessive peak memory during the final resolution pass. - Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored. - Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names. diff --git a/__tests__/resolution-file-read.test.ts b/__tests__/resolution-file-read.test.ts new file mode 100644 index 000000000..f9258d20b --- /dev/null +++ b/__tests__/resolution-file-read.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ReferenceResolver } from '../src/resolution'; +import type { QueryBuilder } from '../src/db/queries'; +import type { ResolutionContext } from '../src/resolution/types'; + +describe('resolution file reads', () => { + let root: string; + let context: ResolutionContext; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-resolution-read-')); + const resolver = new ReferenceResolver(root, {} as QueryBuilder); + context = (resolver as unknown as { context: ResolutionContext }).context; + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads normal source files', () => { + fs.writeFileSync(path.join(root, 'small.ts'), 'export const answer = 42;\n'); + expect(context.readFile('small.ts')).toBe('export const answer = 42;\n'); + }); + + it('rejects an oversized package archive before decoding it as UTF-8', () => { + const relative = 'node_modules/example/react_native_openharmony.har'; + const archive = path.join(root, relative); + fs.mkdirSync(path.dirname(archive), { recursive: true }); + const fd = fs.openSync(archive, 'w'); + try { + fs.writeSync(fd, Buffer.from([0x1f, 0x8b])); + fs.ftruncateSync(fd, 2 * 1024 * 1024); + } finally { + fs.closeSync(fd); + } + + expect(context.readFile(relative)).toBeNull(); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 2b61636b6..86da64e29 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -35,6 +35,7 @@ import ignore, { Ignore } from 'ignore'; import { detectFrameworks } from '../resolution/frameworks'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; +import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits'; /** * Number of files to read in parallel during indexing. @@ -139,13 +140,6 @@ export function hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } -/** - * Skip files larger than this (bytes). Generated bundles, minified JS, and - * vendored blobs blow the WASM heap and the worker-recycle budget for no useful - * symbols. 1 MB covers essentially all hand-written source. - */ -const MAX_FILE_SIZE = 1024 * 1024; - /** * Directory names that are dependency, build, cache, or tooling output across the * languages/frameworks CodeGraph supports — curated from the canonical @@ -1910,18 +1904,18 @@ export class ExtractionOrchestrator { continue; } - // Honour MAX_FILE_SIZE. Without this check, vendored generated + // Honour MAX_SOURCE_FILE_SIZE_BYTES. Without this check, vendored generated // headers, minified bundles, and other multi-MB files get indexed, // wasting WASM heap and the worker recycle budget on inputs with no // useful symbols. The single-file extractFile path already enforces // this; the bulk path used to silently skip the check. - if (stats.size > MAX_FILE_SIZE) { + if (stats.size > MAX_SOURCE_FILE_SIZE_BYTES) { await storeResult(filePath, content, stats, { nodes: [], edges: [], unresolvedReferences: [], errors: [{ - message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, + message: `File exceeds max size (${stats.size} > ${MAX_SOURCE_FILE_SIZE_BYTES})`, filePath, severity: 'warning', code: 'size_exceeded', @@ -2249,14 +2243,14 @@ export class ExtractionOrchestrator { const language = detectLanguage(relativePath, content, loadExtensionOverrides(this.rootDir)); // Check file size - if (stats.size > MAX_FILE_SIZE) { + if (stats.size > MAX_SOURCE_FILE_SIZE_BYTES) { const result: ExtractionResult = { nodes: [], edges: [], unresolvedReferences: [], errors: [ { - message: `File exceeds max size (${stats.size} > ${MAX_FILE_SIZE})`, + message: `File exceeds max size (${stats.size} > ${MAX_SOURCE_FILE_SIZE_BYTES})`, filePath: relativePath, severity: 'warning', code: 'size_exceeded', diff --git a/src/file-limits.ts b/src/file-limits.ts new file mode 100644 index 000000000..4bb4d6da1 --- /dev/null +++ b/src/file-limits.ts @@ -0,0 +1,6 @@ +/** + * Largest source file CodeGraph will parse or read during resolution. Generated + * bundles, minified sources, and dependency archives above this limit provide no + * useful symbols; 1 MB covers essentially all hand-written source. + */ +export const MAX_SOURCE_FILE_SIZE_BYTES = 1024 * 1024; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index aac8077c8..6cb5ac82d 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -22,6 +22,7 @@ import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; +import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits'; import { loadProjectAliases, type AliasMap } from './path-aliases'; import { loadGoModule, type GoModule } from './go-module'; import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages'; @@ -430,6 +431,14 @@ export class ReferenceResolver { } const fullPath = path.join(this.projectRoot, filePath); try { + // Import resolvers may follow package metadata to an archive (`file:*.har`, + // for example). Reject anything extraction would not accept before UTF-8 + // decoding can multiply a large binary blob into gigabytes of V8 heap. + const stats = fs.statSync(fullPath); + if (!stats.isFile() || stats.size > MAX_SOURCE_FILE_SIZE_BYTES) { + this.fileCache.set(filePath, null); + return null; + } const content = fs.readFileSync(fullPath, 'utf-8'); this.fileCache.set(filePath, content); return content;