From 4a3668171555e332439fbdff2fa527e368364d0a Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:02:21 +0300 Subject: [PATCH 1/6] 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 8156760ae2..56accf3263 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 568b782c9f..9d9a760d17 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 ce41c3da94..7b6ac0544f 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -4626,7 +4626,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`); @@ -4634,10 +4644,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); }; @@ -4691,7 +4703,7 @@ 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); } }; // One pass per worker at a time, heaviest first. Handing every pass out @@ -4715,17 +4727,19 @@ export async function synthesizeCallbackEdges( await fanOut; } 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 7c16dbd13d..a912eb0811 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -51,6 +51,7 @@ import { LRUCache } from './lru-cache'; import { JS_BUILT_INS } from './js-builtins'; import { builtinModules } from 'module'; import { parse as parseJsonc } from 'jsonc-parser'; +import { memoryBudgetBytes } from './memory-budget'; const NODE_BUILTINS = new Set(builtinModules); @@ -344,8 +345,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); @@ -353,7 +361,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.nodeByIdCache = new LRUCache(Math.max(limit * 4, 20_000)); this.methodMatchCache = new LRUCache(limit); diff --git a/src/resolution/lru-cache.ts b/src/resolution/lru-cache.ts index 2a597ddbe0..fdc3f7e9d9 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 21e4594e4f9fa2160e1869a22bd7c480225bdf3d Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:04:56 +0300 Subject: [PATCH 2/6] fix(mcp): recycle idle query workers --- __tests__/query-pool.test.ts | 31 +++++++++++++++++++++++ src/mcp/query-pool.ts | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 7c28f48f04..016668bd0c 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -126,6 +126,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 6b1f473991..e7d3e5d7be 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; @@ -108,6 +112,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; /** How long destroy() waits on a worker still starting up (tests shorten it). Default 15s. */ @@ -174,14 +180,17 @@ export class QueryPool { private readonly maxSize: number; private readonly softTimeoutMs: number; private readonly maxRetries: number; + private readonly idleShrinkMs: number; private readonly createWorker: () => PoolWorker; private readonly startSettleMs: number; + 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.startSettleMs = opts.startSettleMs ?? WORKER_START_SETTLE_MS; this.spawnOne(); // one eager warm worker, ready for the first call @@ -260,9 +269,46 @@ 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; + // Idle workers have all started; terminateStarted() still guards the + // mid-start window (WORKER_START_SETTLE_MS) and never rejects. + for (const w of stale) void this.terminateStarted(w); + this.spawnOne(); + } + // A worker died (crash hook, OOM, segfault, exit≠0). Respawn a replacement and // retry its in-flight job once; a job that keeps crashing workers fails // gracefully so it can't loop the pool forever. @@ -319,6 +365,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, @@ -369,6 +416,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 c8fe38df80843f3fd43c6400d9f9f4f0acd2a753 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:07:17 +0300 Subject: [PATCH 3/6] fix(graph): make shortest-path search linear --- __tests__/graph.test.ts | 50 +++++++++++++++++++++++++++++-------- src/graph/traversal.ts | 55 ++++++++++++++++++++++------------------- 2 files changed, 69 insertions(+), 36 deletions(-) diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 875c9ed2a8..e108971e2a 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[] = [ @@ -686,22 +717,21 @@ describe('findPath enqueue-once (#1359)', () => { for (const b of layerB) edges.push({ source: b, target: 'end', kind: 'calls' }); seed(ids, edges); - // Count actual queue insertions, without timing thresholds or replacing SQLite. + // Count target lookups: a node is looked up only when it is first + // discovered and enqueued, so a repeat would mean a duplicate queue entry. + // No timing thresholds, and SQLite stays real. const counts = new Map(); - const push = Array.prototype.push; + const queries = cg['queries']; + const getNodesByIds = queries.getNodesByIds; let result: ReturnType; try { - Array.prototype.push = function (...items) { - for (const item of items) { - if (item && typeof item.nodeId === 'string' && Array.isArray(item.path)) { - counts.set(item.nodeId, (counts.get(item.nodeId) ?? 0) + 1); - } - } - return Reflect.apply(push, this, items); + queries.getNodesByIds = function (ids) { + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + return getNodesByIds.call(this, ids); }; result = cg.findPath('start', 'end', ['calls']); } finally { - Array.prototype.push = push; + queries.getNodesByIds = getNodesByIds; } expect(result?.map((step) => step.node.id)).toEqual(['start', 'a0', 'b0', 'end']); expect(result?.slice(1).every((step) => step.edge?.kind === 'calls')).toBe(true); diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 47368ac6af..0ecd104b29 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -648,25 +648,31 @@ export class GraphTraverser { return null; } - // BFS to find shortest path - const visited = new Set(); + // 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 queue: Array<{ nodeId: string; path: Array<{ node: Node; edge: Edge | null }> }> = [ - { nodeId: fromId, path: [{ node: fromNode, edge: null }] }, - ]; + const parents = new Map(); + const queue: string[] = [fromId]; + let head = 0; - while (queue.length > 0) { - const { nodeId, path } = queue.shift()!; + 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, @@ -674,23 +680,20 @@ export class GraphTraverser { ); if (outgoingEdges.length === 0) continue; - // Batch-fetch only targets not yet visited or queued. - const wantIds = outgoingEdges - .map((e) => e.target) - .filter((id) => !visited.has(id) && !enqueued.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) && !enqueued.has(edge.target)) { - const nextNode = nextNodes.get(edge.target); - if (nextNode) { - enqueued.add(edge.target); - 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 e7e8b1d20f9ed11599e232477a6fa6fb0d095988 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 21 Aug 2026 13:11:04 +0300 Subject: [PATCH 4/6] 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 e108971e2a..0a2276ee37 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; @@ -681,6 +682,29 @@ describe('Traversal edge-completeness & limits (#1086–#1090)', () => { const callees = tGraph(depthNodes, edges).getCallees('t', 2); expect(callees.map((c) => c.node.id).sort()).toEqual(['a', 'b', 'c']); }); + + 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); + }); }); describe('findPath enqueue-once (#1359)', () => { diff --git a/src/db/queries.ts b/src/db/queries.ts index de9b1dc8c5..fa8674d025 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2192,8 +2192,8 @@ export class QueryBuilder { * Get outgoing edges from a node. Preserve the source/kind index order * (calls before imports/references), then break ties deterministically. */ - 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]; @@ -2208,6 +2208,10 @@ export class QueryBuilder { } sql += ' ORDER BY kind, target, line, col'; + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } @@ -2225,10 +2229,20 @@ export class QueryBuilder { * displace actual calls in capped caller lists. Keep deterministic ties * without changing the target/kind index's established kind precedence. */ - getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { - if (kinds && kinds.length > 0) { - const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY kind, source, line, col`; - const rows = this.edgeKindStmt(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); + } + sql += ' ORDER BY kind, source, line, col'; + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } + const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 0ecd104b29..a45bc4e583 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 */ @@ -535,7 +539,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: [] }; @@ -544,17 +548,23 @@ export class GraphTraverser { const nodes = new Map(); const edges: Edge[] = []; const visited = new Map(); + 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, new Set()); + this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, new Set(), budget); return { nodes, edges, roots: [nodeId], + truncated: budget.truncated || undefined, }; } @@ -565,7 +575,8 @@ export class GraphTraverser { nodes: Map, edges: Edge[], visited: Map, - expanded: Set + expanded: 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 @@ -586,18 +597,25 @@ 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 && this.nearerThanBefore(visited, childNode.id, currentDepth)) { if (!nodes.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, expanded); + this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, expanded, budget); } } } @@ -608,7 +626,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)); @@ -620,10 +643,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 recorded on its first expansion only, 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; + } if (firstExpansion) edges.push(edge); if (this.nearerThanBefore(visited, sourceNode.id, currentDepth + 1)) { nodes.set(sourceNode.id, sourceNode); - this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, expanded); + this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, expanded, budget); } } } diff --git a/src/index.ts b/src/index.ts index 6fa4b8b697..795bba3439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { FindRelevantContextOptions, UnresolvedReference, IndexHealth, + ImpactOptions, } from './types'; import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db'; import { WalCheckpointValve, resolveWalValveMb } from './db/wal-valve'; @@ -2319,8 +2320,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 8aebac9588..ad752302b7 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3262,24 +3262,40 @@ 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); answerPaths.add(n.filePath); } 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. @@ -8443,9 +8459,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 f30080002f..4738dea010 100644 --- a/src/types.ts +++ b/src/types.ts @@ -418,6 +418,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 d8d586aa76ef2f11b3fee88380535a722fb46a40 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sun, 27 Sep 2026 17:27:55 +0300 Subject: [PATCH 5/6] docs(changelog): note the resolution memory and graph bound fixes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3a3cde4f..2a6252ac2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading. - In React and Vue, a tag for a component the file imports from a package, like `