gitnexus 1.6.6-rc.156 → 1.6.6-rc.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,22 @@ const writeFatalToStderr = (label, err) => {
41
41
  realStderrWrite(`\n ${label}: ${message}\n`);
42
42
  if (isErr && err.stack)
43
43
  realStderrWrite(`${err.stack}\n`);
44
+ // Walk and print the `cause` chain. The phase runner wraps the underlying
45
+ // failure as `new Error("Phase 'X' failed: …", { cause })`, so the original
46
+ // error (e.g. a WorkerPoolDispatchError carrying the worker-side stack from
47
+ // #2068) is only reachable via `.cause`. Without this the user sees the
48
+ // wrapper's main-thread stack and never the real frame. `cause.stack` already
49
+ // begins with the cause's message, so we print the stack alone (not message +
50
+ // stack) to avoid repeating it. Depth-bounded so a cyclic `cause` can't loop
51
+ // (the phase runner wraps one level; the bound leaves headroom for future
52
+ // nesting); uses realStderrWrite so the redirected console.error's ANSI
53
+ // clear-line wrapping can't erase it (#1169).
54
+ const MAX_CAUSE_DEPTH = 5;
55
+ let cause = isErr ? err.cause : undefined;
56
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
57
+ realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`);
58
+ cause = cause.cause;
59
+ }
44
60
  };
45
61
  let fatalHandlersInstalled = false;
46
62
  /**
@@ -9,14 +9,12 @@
9
9
  *
10
10
  * - `processRoutesFromExtracted` — CALLS edges from framework routes
11
11
  * (e.g. Laravel) to their controller methods.
12
- * - `processNextjsFetchRoutes` / `extractFetchCallsFromFiles` /
13
- * `extractConsumerAccessedKeys` — FETCHES edges from `fetch()` calls to
14
- * Next.js Route nodes.
12
+ * - `processNextjsFetchRoutes` / `extractConsumerAccessedKeys` — FETCHES edges
13
+ * from `fetch()` calls to Next.js Route nodes.
15
14
  * - `buildExportedTypeMapFromGraph` — exported symbol → return/declared type
16
15
  * map, consumed by the cross-file enrichment pass.
17
16
  */
18
17
  import { KnowledgeGraph } from '../graph/types.js';
19
- import { ASTCache } from './ast-cache.js';
20
18
  import type { SemanticModel, SymbolTableReader } from './model/index.js';
21
19
  import type { ExtractedRoute, ExtractedFetchCall } from './workers/parse-worker.js';
22
20
  /** Per-file resolved type bindings for exported symbols.
@@ -76,12 +74,3 @@ export declare const extractConsumerAccessedKeys: (content: string) => string[];
76
74
  */
77
75
  export declare const processNextjsFetchRoutes: (graph: KnowledgeGraph, fetchCalls: ExtractedFetchCall[], routeRegistry: Map<string, string>, // routeURL → handlerFilePath
78
76
  consumerContents?: Map<string, string>) => void;
79
- /**
80
- * Extract fetch() calls from source files (sequential path).
81
- * Workers handle this via tree-sitter captures in parse-worker; this function
82
- * provides the same extraction for the sequential fallback path.
83
- */
84
- export declare const extractFetchCallsFromFiles: (files: {
85
- path: string;
86
- content: string;
87
- }[], astCache: ASTCache) => Promise<ExtractedFetchCall[]>;
@@ -9,20 +9,13 @@
9
9
  *
10
10
  * - `processRoutesFromExtracted` — CALLS edges from framework routes
11
11
  * (e.g. Laravel) to their controller methods.
12
- * - `processNextjsFetchRoutes` / `extractFetchCallsFromFiles` /
13
- * `extractConsumerAccessedKeys` — FETCHES edges from `fetch()` calls to
14
- * Next.js Route nodes.
12
+ * - `processNextjsFetchRoutes` / `extractConsumerAccessedKeys` — FETCHES edges
13
+ * from `fetch()` calls to Next.js Route nodes.
15
14
  * - `buildExportedTypeMapFromGraph` — exported symbol → return/declared type
16
15
  * map, consumed by the cross-file enrichment pass.
17
16
  */
18
- import Parser from 'tree-sitter';
19
- import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
20
- import { getProvider } from './languages/index.js';
21
17
  import { generateId } from '../../lib/utils.js';
22
- import { getLanguageFromFilename } from '../../_shared/index.js';
23
18
  import { yieldToEventLoop } from './utils/event-loop.js';
24
- import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
25
- import { getTreeSitterBufferSize } from './constants.js';
26
19
  import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
27
20
  import { extractReturnTypeName } from './type-extractors/shared.js';
28
21
  const MAX_EXPORTS_PER_FILE = 500;
@@ -408,73 +401,3 @@ consumerContents) => {
408
401
  }
409
402
  }
410
403
  };
411
- /**
412
- * Extract fetch() calls from source files (sequential path).
413
- * Workers handle this via tree-sitter captures in parse-worker; this function
414
- * provides the same extraction for the sequential fallback path.
415
- */
416
- export const extractFetchCallsFromFiles = async (files, astCache) => {
417
- const parser = await loadParser();
418
- const result = [];
419
- for (const file of files) {
420
- const language = getLanguageFromFilename(file.path);
421
- if (!language)
422
- continue;
423
- if (!isLanguageAvailable(language))
424
- continue;
425
- const provider = getProvider(language);
426
- const queryStr = provider.treeSitterQueries;
427
- if (!queryStr)
428
- continue;
429
- await loadLanguage(language, file.path);
430
- let tree = astCache.get(file.path);
431
- if (!tree) {
432
- const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
433
- try {
434
- tree = parseSourceSafe(parser, parseContent, undefined, {
435
- bufferSize: getTreeSitterBufferSize(parseContent),
436
- });
437
- }
438
- catch {
439
- continue;
440
- }
441
- astCache.set(file.path, tree);
442
- }
443
- let matches;
444
- try {
445
- const lang = parser.getLanguage();
446
- const query = new Parser.Query(lang, queryStr);
447
- matches = query.matches(tree.rootNode);
448
- }
449
- catch {
450
- continue;
451
- }
452
- for (const match of matches) {
453
- const captureMap = {};
454
- match.captures.forEach((c) => (captureMap[c.name] = c.node));
455
- if (captureMap['route.fetch']) {
456
- const urlNode = captureMap['route.url'] ?? captureMap['route.template_url'];
457
- if (urlNode) {
458
- result.push({
459
- filePath: file.path,
460
- fetchURL: urlNode.text,
461
- lineNumber: captureMap['route.fetch'].startPosition.row,
462
- });
463
- }
464
- }
465
- else if (captureMap['http_client'] && captureMap['http_client.url']) {
466
- const method = captureMap['http_client.method']?.text;
467
- const url = captureMap['http_client.url'].text;
468
- const HTTP_CLIENT_ONLY = new Set(['head', 'options', 'request', 'ajax']);
469
- if (method && HTTP_CLIENT_ONLY.has(method) && url.startsWith('/')) {
470
- result.push({
471
- filePath: file.path,
472
- fetchURL: url,
473
- lineNumber: captureMap['http_client'].startPosition.row,
474
- });
475
- }
476
- }
477
- }
478
- }
479
- return result;
480
- };
@@ -4,7 +4,8 @@
4
4
  * Determines whether a symbol (function, class, etc.) is exported/public
5
5
  * in its language. This is a pure function — safe for use in worker threads.
6
6
  *
7
- * Shared between parse-worker.ts (worker pool) and parsing-processor.ts (sequential fallback).
7
+ * Used by the language providers during worker parsing (parse-worker.ts) — the
8
+ * sole parse path. (Sequential parsing was removed.)
8
9
  */
9
10
  import { type SyntaxNode } from './utils/ast-helpers.js';
10
11
  /** Handler type: given a node and symbol name, return true if the symbol is exported/public. */
@@ -4,7 +4,8 @@
4
4
  * Determines whether a symbol (function, class, etc.) is exported/public
5
5
  * in its language. This is a pure function — safe for use in worker threads.
6
6
  *
7
- * Shared between parse-worker.ts (worker pool) and parsing-processor.ts (sequential fallback).
7
+ * Used by the language providers during worker parsing (parse-worker.ts) — the
8
+ * sole parse path. (Sequential parsing was removed.)
8
9
  */
9
10
  import { findSiblingChild } from './utils/ast-helpers.js';
10
11
  // ============================================================================
@@ -1,7 +1,3 @@
1
- export interface FileEntry {
2
- path: string;
3
- content: string;
4
- }
5
1
  /** Lightweight entry — path + size from stat, no content in memory */
6
2
  export interface ScannedFile {
7
3
  path: string;
@@ -21,8 +17,3 @@ export declare const walkRepositoryPaths: (repoPath: string, onProgress?: (curre
21
17
  * Returns a Map for O(1) lookup. Silently skips files that fail to read.
22
18
  */
23
19
  export declare const readFileContents: (repoPath: string, relativePaths: string[]) => Promise<Map<string, string>>;
24
- /**
25
- * Legacy API — scans and reads everything into memory.
26
- * Used by sequential fallback path only.
27
- */
28
- export declare const walkRepository: (repoPath: string, onProgress?: (current: number, total: number, filePath: string) => void) => Promise<FileEntry[]>;
@@ -111,14 +111,3 @@ export const readFileContents = async (repoPath, relativePaths) => {
111
111
  }
112
112
  return contents;
113
113
  };
114
- /**
115
- * Legacy API — scans and reads everything into memory.
116
- * Used by sequential fallback path only.
117
- */
118
- export const walkRepository = async (repoPath, onProgress) => {
119
- const scanned = await walkRepositoryPaths(repoPath, onProgress);
120
- const contents = await readFileContents(repoPath, scanned.map((f) => f.path));
121
- return scanned
122
- .filter((f) => contents.has(f.path))
123
- .map((f) => ({ path: f.path, content: contents.get(f.path) }));
124
- };
@@ -26,9 +26,10 @@ import { getTreeSitterBufferSize } from '../../constants.js';
26
26
  import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
27
27
  import { pythonFunctionDefinitionLabel } from './simple-hooks.js';
28
28
  export function emitPythonScopeCaptures(sourceText, _filePath, cachedTree) {
29
- // Skip the parse when the caller (parse phase's ASTCache) already
30
- // produced a Tree for this source. Cache miss = re-parse, same as
31
- // before. The cachedTree parameter is typed as `unknown` at the
29
+ // Skip the parse when the caller (the scope-resolution orchestrator's
30
+ // `treeCache`) already produced a Tree for this source — empty under
31
+ // worker-pool runs, so cache miss = re-parse. The cachedTree parameter
32
+ // is typed as `unknown` at the
32
33
  // contract layer (see `LanguageProvider.emitScopeCaptures`); cast
33
34
  // here at the use site.
34
35
  let tree = cachedTree;
@@ -20,7 +20,6 @@ import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCac
20
20
  import { clearParsedFileStore, persistParsedFileChunk, getDurableParsedFileDir, loadDurableParsedFileIndex, restoreDurableParsedFileShard, } from '../../../storage/parsedfile-store.js';
21
21
  import { processRoutesFromExtracted, buildExportedTypeMapFromGraph, } from '../call-processor.js';
22
22
  import { createSemanticModel } from '../model/index.js';
23
- import { createASTCache } from '../ast-cache.js';
24
23
  import { getLanguageFromFilename } from '../../../_shared/index.js';
25
24
  import { readFileContents } from '../filesystem-walker.js';
26
25
  import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
@@ -328,13 +327,6 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
328
327
  }
329
328
  };
330
329
  let filesParsedSoFar = 0;
331
- // Chunk-local tree-sitter cache, cleared between chunks — call / heritage /
332
- // import processors read it during parse to avoid re-parsing within the same
333
- // chunk. (The former cross-phase `scopeTreeCache` was only ever populated by
334
- // the sequential parser, which has been removed; workers can't return native
335
- // Trees across the MessageChannel, so scope-resolution re-parses as needed.)
336
- const maxChunkFiles = chunks.reduce((max, c) => Math.max(max, c.length), 0);
337
- const astCache = createASTCache(maxChunkFiles);
338
330
  const exportedTypeMap = new Map();
339
331
  const bindingAccumulator = new BindingAccumulator();
340
332
  const allFetchCalls = [];
@@ -494,7 +486,6 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
494
486
  }
495
487
  }
496
488
  filesParsedSoFar += chunkFiles.length;
497
- astCache.clear();
498
489
  if (verboseThroughputLog && chunkStartMs !== null) {
499
490
  const elapsedMs = Date.now() - chunkStartMs;
500
491
  const filesPerSec = elapsedMs > 0 ? (chunkFiles.length * 1000) / elapsedMs : 0;
@@ -747,7 +738,6 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
747
738
  // Finalize the accumulator and propagate any fixpoint-inferred exports before
748
739
  // `crossFile` disposes it downstream. Wrapped in try/catch so a cleanup
749
740
  // failure never masks a real parse error; disposal stays with `crossFile`.
750
- astCache.clear();
751
741
  try {
752
742
  bindingAccumulator.finalize();
753
743
  const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap);
@@ -1888,7 +1888,21 @@ parentPort.on('message', (msg) => {
1888
1888
  }
1889
1889
  }
1890
1890
  catch (err) {
1891
- const message = err instanceof Error ? err.message : String(err);
1892
- parentPort.postMessage({ type: 'error', error: message });
1891
+ // Carry the worker-side stack across the MessageChannel, not just the
1892
+ // message. Without this, an unexpected worker throw (e.g. the minified
1893
+ // `this.#<x> is not a function` family) reaches the operator as a bare
1894
+ // one-liner with no file:line — exactly what made #2068 undebuggable. The
1895
+ // pool embeds `errorStack` into its death/circuit-breaker reason so the
1896
+ // surfaced "Phase 'parse' failed" message points at the real frame (the
1897
+ // stack's first line already carries the error's type + message). We send
1898
+ // primitive fields (not the raw Error) so a non-cloneable `cause` payload
1899
+ // can never turn the report itself into a `messageerror`. `errorStack` is
1900
+ // optional on the wire, so an older pool ignores it.
1901
+ const e = err instanceof Error ? err : new Error(String(err));
1902
+ parentPort.postMessage({
1903
+ type: 'error',
1904
+ error: e.message,
1905
+ errorStack: e.stack,
1906
+ });
1893
1907
  }
1894
1908
  });
@@ -356,6 +356,24 @@ function withStderr(worker, message) {
356
356
  const tail = workerStderrTail(worker);
357
357
  return tail ? `${message}. Worker stderr:\n${tail}` : message;
358
358
  }
359
+ /**
360
+ * Build a worker-death reason string that carries the worker-side stack when one
361
+ * is available (#2068). The stack is appended AFTER the `Worker N error: <msg>`
362
+ * prefix so every prefix/substring consumer downstream — recoverAndResume →
363
+ * handleWorkerDeath → the circuit-breaker `WorkerPoolDispatchError` message, and
364
+ * the tests that regex-match those — keeps working unchanged, while the operator
365
+ * now gets the real frame instead of a bare one-liner. The stack's first line is
366
+ * normally the message itself; keeping both is harmless and the indented block
367
+ * scans cleanly in a log. The stack is capped at WORKER_STDERR_TAIL_LIMIT,
368
+ * mirroring the sibling stderr-tail bound, so a pathological error type (or a
369
+ * raised `Error.stackTraceLimit`) can't bloat the death reason. `stack` is
370
+ * `undefined` for an older worker build (or a thrown non-Error), in which case
371
+ * the reason is exactly the prior message-only form.
372
+ */
373
+ function workerErrorReason(workerIndex, message, stack) {
374
+ const base = `Worker ${workerIndex} error: ${message}`;
375
+ return stack ? `${base}\n worker stack:\n${stack.slice(0, WORKER_STDERR_TAIL_LIMIT)}` : base;
376
+ }
359
377
  /**
360
378
  * Wait for a freshly-spawned replacement worker to emit the
361
379
  * `{type:'ready'}` handshake from `parse-worker.ts` before treating its
@@ -1338,7 +1356,7 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1338
1356
  else if (msg.type === 'error') {
1339
1357
  settled = true;
1340
1358
  cleanup();
1341
- void recoverAndResume(`Worker ${workerIndex} error: ${msg.error}`, resolveExcludePaths());
1359
+ void recoverAndResume(workerErrorReason(workerIndex, msg.error, msg.errorStack), resolveExcludePaths());
1342
1360
  }
1343
1361
  else if (msg.type === 'result') {
1344
1362
  if (!waitingForFlush) {
@@ -1379,7 +1397,12 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1379
1397
  if (!settled) {
1380
1398
  settled = true;
1381
1399
  cleanup();
1382
- void recoverAndResume(`Worker ${workerIndex} error: ${err.message}`, resolveExcludePaths());
1400
+ // The Node 'error' event fires on an UNCAUGHT worker throw (one that
1401
+ // escaped the worker's own try/catch, or an async rejection). Unlike
1402
+ // the `{type:'error'}` message, the event delivers a real Error whose
1403
+ // `.stack` is the worker-side frame — carry it so the surfaced reason
1404
+ // points at the actual failure site, not just `err.message` (#2068).
1405
+ void recoverAndResume(workerErrorReason(workerIndex, err.message, err.stack), resolveExcludePaths());
1383
1406
  }
1384
1407
  };
1385
1408
  const exitHandler = (code) => {
@@ -7,6 +7,7 @@ import path from 'path';
7
7
  import os from 'os';
8
8
  import crypto from 'crypto';
9
9
  import lbug from '@ladybugdb/core';
10
+ import { closeQueryResults } from './query-result-utils.js';
10
11
  import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, STALE_HASH_SENTINEL, } from './schema.js';
11
12
  import { streamAllCSVsToDisk } from './csv-generator.js';
12
13
  import { extensionManager } from './extension-loader.js';
@@ -153,8 +154,19 @@ const DB_LOCK_RETRY_DELAY_MS = 500;
153
154
  * analyze` and either already happened or will happen on the next run.
154
155
  */
155
156
  export const isReadOnlyDbError = (err) => {
156
- const msg = err instanceof Error ? err.message : String(err);
157
- return /read-only database/i.test(msg);
157
+ // Walk the `cause` chain (bounded) so a wrapped read-only error — e.g. the
158
+ // pool adapter's `new Error('…read-only.', { cause: nativeReadOnlyErr })` —
159
+ // is still detected by callers that only see the wrapper (#2068 follow-up).
160
+ // The same strict regex is re-applied at each level, so a non-read-only
161
+ // chain stays false; the depth bound guards a cyclic `cause`.
162
+ let cur = err;
163
+ for (let depth = 0; depth < 5 && cur != null; depth++) {
164
+ const msg = cur instanceof Error ? cur.message : String(cur);
165
+ if (/read-only database/i.test(msg))
166
+ return true;
167
+ cur = cur instanceof Error ? cur.cause : undefined;
168
+ }
169
+ return false;
158
170
  };
159
171
  const isMissingFileError = (err) => {
160
172
  const errno = err;
@@ -301,13 +313,10 @@ const runWithSessionLock = async (operation) => {
301
313
  }
302
314
  };
303
315
  const normalizeCopyPath = (filePath) => toNativeSafePath(filePath).replace(/\\/g, '/');
316
+ // Single-result convenience wrapper over the shared best-effort closer
317
+ // (drainQueryResult / readQueryRows close one cursor at a time).
304
318
  const closeQueryResult = async (result) => {
305
- try {
306
- await result.close();
307
- }
308
- catch {
309
- // Best-effort cleanup only.
310
- }
319
+ await closeQueryResults(result);
311
320
  };
312
321
  const drainQueryResult = async (queryResult) => {
313
322
  const results = Array.isArray(queryResult) ? queryResult : [queryResult];
@@ -1544,8 +1553,9 @@ export const queryImporters = async (targetFilePath) => {
1544
1553
  WHERE r.type = 'IMPORTS' AND b.filePath = '${escaped}'
1545
1554
  RETURN DISTINCT a.filePath AS importer
1546
1555
  `;
1556
+ let queryResult;
1547
1557
  try {
1548
- const queryResult = await conn.query(cypher);
1558
+ queryResult = await conn.query(cypher);
1549
1559
  const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
1550
1560
  const rows = await result.getAll();
1551
1561
  const out = [];
@@ -1559,6 +1569,10 @@ export const queryImporters = async (targetFilePath) => {
1559
1569
  catch {
1560
1570
  return [];
1561
1571
  }
1572
+ finally {
1573
+ if (queryResult)
1574
+ await closeQueryResults(queryResult);
1575
+ }
1562
1576
  };
1563
1577
  /**
1564
1578
  * Drop every Community and Process node (and their MEMBER_OF /
@@ -1573,8 +1587,9 @@ export const deleteAllCommunitiesAndProcesses = async () => {
1573
1587
  }
1574
1588
  let nodesDeleted = 0;
1575
1589
  for (const label of ['Community', 'Process']) {
1590
+ let countResult;
1576
1591
  try {
1577
- const countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
1592
+ countResult = await conn.query(`MATCH (n:${label}) RETURN count(n) AS cnt`);
1578
1593
  const result = Array.isArray(countResult) ? countResult[0] : countResult;
1579
1594
  const rows = await result.getAll();
1580
1595
  const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
@@ -1586,6 +1601,10 @@ export const deleteAllCommunitiesAndProcesses = async () => {
1586
1601
  catch {
1587
1602
  // Table may not exist yet on a freshly-initialized DB — fine.
1588
1603
  }
1604
+ finally {
1605
+ if (countResult)
1606
+ await closeQueryResults(countResult);
1607
+ }
1589
1608
  }
1590
1609
  return { nodesDeleted };
1591
1610
  };
@@ -17,6 +17,7 @@
17
17
  import fs from 'fs/promises';
18
18
  import lbug from '@ladybugdb/core';
19
19
  import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
20
+ import { closeQueryResults } from './query-result-utils.js';
20
21
  import { createLbugDatabase, isWalCorruptionError, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
21
22
  import { isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
22
23
  const pool = new Map();
@@ -112,6 +113,18 @@ function closeOne(repoId) {
112
113
  if (!entry)
113
114
  return;
114
115
  entry.closed = true;
116
+ // Reject any callers still queued for a connection: the pool is going away
117
+ // (re-init / teardown / LRU eviction), so they must fail fast with an
118
+ // actionable error instead of hanging until WAITER_TIMEOUT_MS and then
119
+ // surfacing a misleading "pool exhausted" (#2068 follow-up). Draining the
120
+ // queue also guarantees checkin() below finds no waiter expecting a
121
+ // connection, so a connection returned after close is simply closed.
122
+ if (entry.waiters.length > 0) {
123
+ const closedErr = new Error(`LadybugDB connection pool closed for repo "${repoId}" (re-init/teardown); retry the query.`);
124
+ for (const waiter of entry.waiters)
125
+ waiter.reject(closedErr);
126
+ entry.waiters.length = 0;
127
+ }
115
128
  // Close available connections — fire-and-forget with .catch() to prevent
116
129
  // unhandled rejections. Native close() returns Promise<void> but can crash
117
130
  // the N-API destructor on macOS/Windows; deferring to process exit lets
@@ -575,15 +588,21 @@ function checkout(entry) {
575
588
  }
576
589
  // At capacity — queue the caller with a timeout.
577
590
  return new Promise((resolve, reject) => {
578
- const waiter = (conn) => {
579
- clearTimeout(timer);
580
- resolve(conn);
591
+ const waiter = {
592
+ resolve: (conn) => {
593
+ clearTimeout(timer);
594
+ resolve(conn);
595
+ },
596
+ reject: (err) => {
597
+ clearTimeout(timer);
598
+ reject(err);
599
+ },
581
600
  };
582
601
  const timer = setTimeout(() => {
583
602
  const idx = entry.waiters.indexOf(waiter);
584
603
  if (idx !== -1)
585
604
  entry.waiters.splice(idx, 1);
586
- reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
605
+ waiter.reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
587
606
  }, WAITER_TIMEOUT_MS);
588
607
  entry.waiters.push(waiter);
589
608
  });
@@ -604,7 +623,7 @@ function checkin(entry, conn) {
604
623
  if (entry.waiters.length > 0) {
605
624
  // Hand directly to the next waiter — no intermediate available state
606
625
  const waiter = entry.waiters.shift();
607
- waiter(conn);
626
+ waiter.resolve(conn);
608
627
  }
609
628
  else {
610
629
  entry.checkedOut--;
@@ -639,24 +658,36 @@ export const executeParameterized = async (repoId, cypher, params) => {
639
658
  const conn = await checkout(entry);
640
659
  silenceStdout();
641
660
  activeQueryCount++;
661
+ let queryResult;
642
662
  try {
643
663
  const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
644
664
  if (!stmt.isSuccess()) {
645
665
  const errMsg = await stmt.getErrorMessage();
646
666
  throw new Error(`Prepare failed: ${errMsg}`);
647
667
  }
648
- const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
668
+ queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
649
669
  const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
650
670
  const rows = await result.getAll();
651
671
  return rows;
652
672
  }
653
673
  catch (err) {
654
674
  if (isReadOnlyDbError(err)) {
655
- throw new Error('Write operations are not allowed. The pool adapter is read-only.');
675
+ // Preserve the native error as `cause` so the original frame/message is
676
+ // not lost behind the friendly read-only message (#2068 follow-up).
677
+ throw new Error('Write operations are not allowed. The pool adapter is read-only.', {
678
+ cause: err,
679
+ });
656
680
  }
657
681
  throw err;
658
682
  }
659
683
  finally {
684
+ // Close the native QueryResult cursor(s) before returning the connection —
685
+ // getAll() drains rows but does not release the native cursor, so without
686
+ // this the cursor leaks for the connection's lifetime (#2068 follow-up).
687
+ // Best-effort via the shared helper; never masks the query result or a real
688
+ // error.
689
+ if (queryResult)
690
+ await closeQueryResults(queryResult);
660
691
  activeQueryCount--;
661
692
  restoreStdout();
662
693
  checkin(entry, conn);
@@ -0,0 +1,19 @@
1
+ import lbug from '@ladybugdb/core';
2
+ /**
3
+ * Best-effort close of one or more native `QueryResult` cursors.
4
+ *
5
+ * `result.getAll()` materializes rows into a JS array but does not release the
6
+ * native cursor — leaving it open holds native resources for the connection's
7
+ * lifetime. Both the pooled adapter (`pool-adapter.ts`) and the direct adapter
8
+ * (`lbug-adapter.ts`) must release cursors after reading; this is the single
9
+ * shared implementation so neither re-rolls the array-normalize + swallow loop
10
+ * (a #2068 follow-up de-dup). Lives in its own leaf module so the two adapters
11
+ * depend on it rather than on each other.
12
+ *
13
+ * `conn.execute()` can return either a single `QueryResult` or an array
14
+ * (multi-statement); this normalizes both. Each close is independent and
15
+ * best-effort: a failing or absent `close()` on one cursor never throws and
16
+ * never prevents the others from closing — a cleanup failure must not mask the
17
+ * query result or a real error at the call site.
18
+ */
19
+ export declare function closeQueryResults(queryResult: lbug.QueryResult | lbug.QueryResult[]): Promise<void>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Best-effort close of one or more native `QueryResult` cursors.
3
+ *
4
+ * `result.getAll()` materializes rows into a JS array but does not release the
5
+ * native cursor — leaving it open holds native resources for the connection's
6
+ * lifetime. Both the pooled adapter (`pool-adapter.ts`) and the direct adapter
7
+ * (`lbug-adapter.ts`) must release cursors after reading; this is the single
8
+ * shared implementation so neither re-rolls the array-normalize + swallow loop
9
+ * (a #2068 follow-up de-dup). Lives in its own leaf module so the two adapters
10
+ * depend on it rather than on each other.
11
+ *
12
+ * `conn.execute()` can return either a single `QueryResult` or an array
13
+ * (multi-statement); this normalizes both. Each close is independent and
14
+ * best-effort: a failing or absent `close()` on one cursor never throws and
15
+ * never prevents the others from closing — a cleanup failure must not mask the
16
+ * query result or a real error at the call site.
17
+ */
18
+ export async function closeQueryResults(queryResult) {
19
+ const results = Array.isArray(queryResult) ? queryResult : [queryResult];
20
+ for (const r of results) {
21
+ try {
22
+ await r?.close();
23
+ }
24
+ catch {
25
+ // Best-effort cleanup only.
26
+ }
27
+ }
28
+ }
@@ -143,7 +143,20 @@ function logQueryError(context, err) {
143
143
  const msg = err instanceof Error ? err.message : String(err);
144
144
  logger.error({ context, err: msg }, 'GitNexus query failed');
145
145
  }
146
- const isReadOnlyDbError = (err) => /read-only database/i.test(err instanceof Error ? err.message : String(err));
146
+ const isReadOnlyDbError = (err) => {
147
+ // Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
148
+ // pool adapter's `{ cause }` wrapper) is still detected here — this is the
149
+ // copy the MCP cypher handler uses to surface its curated read-only message
150
+ // (#2068 follow-up). Mirrors lbug-adapter's isReadOnlyDbError.
151
+ let cur = err;
152
+ for (let depth = 0; depth < 5 && cur != null; depth++) {
153
+ const msg = cur instanceof Error ? cur.message : String(cur);
154
+ if (/read-only database/i.test(msg))
155
+ return true;
156
+ cur = cur instanceof Error ? cur.cause : undefined;
157
+ }
158
+ return false;
159
+ };
147
160
  /**
148
161
  * Per-query latency telemetry for production aggregation (#553).
149
162
  *
@@ -17,9 +17,12 @@ export interface PipelineResult {
17
17
  */
18
18
  resolutionOutcomes: readonly ResolutionOutcome[];
19
19
  /**
20
- * True if the parse phase spawned a worker pool for this run. False means
21
- * the sequential fallback handled every chunk. Primarily a test affordance
22
- * so regression suites can prove which path executed.
20
+ * True if a worker pool was actually constructed for this run. The worker
21
+ * pool is the sole parse path (sequential parsing was removed). False means
22
+ * no pool was needed: either there were no parseable files, or every chunk
23
+ * was a parse-cache hit and the cached worker output was replayed without
24
+ * spawning workers (a warm all-cache-hit run, #2038). Primarily a test
25
+ * affordance so regression suites can prove the pool engaged.
23
26
  */
24
27
  usedWorkerPool: boolean;
25
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.6-rc.156",
3
+ "version": "1.6.6-rc.157",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -70,7 +70,6 @@
70
70
  "ignore": "^7.0.5",
71
71
  "js-yaml": "^4.1.1",
72
72
  "jsonc-parser": "^3.3.1",
73
- "lru-cache": "^11.0.0",
74
73
  "mnemonist": "^0.40.3",
75
74
  "onnxruntime-node": "^1.24.0",
76
75
  "pandemonium": "^2.4.0",
@@ -1,26 +0,0 @@
1
- import Parser from 'tree-sitter';
2
- /**
3
- * Minimal structural shape consumers need when reading Trees back
4
- * through a phase-dependency boundary. Declared here so phases that
5
- * receive ASTCache via `getPhaseOutput<...>` don't hand-roll their
6
- * own inline structural types that silently drift when ASTCache's
7
- * contract changes.
8
- *
9
- * Typed as `unknown` at the Tree boundary because consumers on the
10
- * other side of the phase-output map don't share tree-sitter's type
11
- * graph (e.g. COBOL's standalone processor).
12
- */
13
- export interface ASTCacheReader {
14
- get(filePath: string): unknown;
15
- clear(): void;
16
- }
17
- export interface ASTCache extends ASTCacheReader {
18
- get: (filePath: string) => Parser.Tree | undefined;
19
- set: (filePath: string, tree: Parser.Tree) => void;
20
- clear: () => void;
21
- stats: () => {
22
- size: number;
23
- maxSize: number;
24
- };
25
- }
26
- export declare const createASTCache: (maxSize?: number) => ASTCache;
@@ -1,48 +0,0 @@
1
- import { LRUCache } from 'lru-cache';
2
- import { logger } from '../logger.js';
3
- export const createASTCache = (maxSize = 50) => {
4
- const effectiveMax = Math.max(maxSize, 1);
5
- // Initialize the cache with a 'dispose' handler
6
- // This is the magic: When an item is evicted (dropped), this runs automatically.
7
- const cache = new LRUCache({
8
- max: effectiveMax,
9
- dispose: (tree) => {
10
- try {
11
- // NOTE: web-tree-sitter has tree.delete(); native tree-sitter
12
- // trees are GC-managed and .delete is absent (no-op here).
13
- //
14
- // Single-owner invariant (load-bearing under WASM): a given
15
- // Parser.Tree reference must live in AT MOST ONE ASTCache
16
- // that disposes. The parse-phase chunk-local cache clears
17
- // between chunks; the cross-phase `scopeTreeCache` (also an
18
- // ASTCache today) holds the same Tree by reference. Under
19
- // native tree-sitter this is benign (dispose is a no-op).
20
- // If/when GitNexus adopts web-tree-sitter for sequential
21
- // parsing, the cross-phase cache must either (a) skip
22
- // writing Trees that are already owned by a disposing cache,
23
- // or (b) use tree.copy() per entry. Failing to pick one
24
- // will hand freed memory to scope-resolution.
25
- tree.delete?.();
26
- }
27
- catch (e) {
28
- logger.warn({ e }, 'Failed to delete tree from WASM memory');
29
- }
30
- },
31
- });
32
- return {
33
- get: (filePath) => {
34
- const tree = cache.get(filePath);
35
- return tree; // Returns undefined if not found
36
- },
37
- set: (filePath, tree) => {
38
- cache.set(filePath, tree);
39
- },
40
- clear: () => {
41
- cache.clear();
42
- },
43
- stats: () => ({
44
- size: cache.size,
45
- maxSize: effectiveMax,
46
- }),
47
- };
48
- };
@@ -1,22 +0,0 @@
1
- /**
2
- * Inline ORM query extraction (sequential fallback path).
3
- *
4
- * Extracts Prisma and Supabase query calls from source content using
5
- * regex patterns. Used by the sequential parse path when workers are
6
- * not available — the worker path extracts ORM queries via tree-sitter
7
- * queries instead.
8
- *
9
- * @module
10
- */
11
- import type { ExtractedORMQuery } from '../workers/parse-worker.js';
12
- /**
13
- * Extract ORM query calls from file content using regex.
14
- *
15
- * Fast-path: skips files that don't contain `prisma.` or `supabase.from`.
16
- * Results are appended to the `out` array (push pattern avoids allocation).
17
- *
18
- * @param filePath Relative path of the source file
19
- * @param content File content string
20
- * @param out Output array to append extracted queries to
21
- */
22
- export declare function extractORMQueriesInline(filePath: string, content: string, out: ExtractedORMQuery[]): void;
@@ -1,92 +0,0 @@
1
- /**
2
- * Inline ORM query extraction (sequential fallback path).
3
- *
4
- * Extracts Prisma and Supabase query calls from source content using
5
- * regex patterns. Used by the sequential parse path when workers are
6
- * not available — the worker path extracts ORM queries via tree-sitter
7
- * queries instead.
8
- *
9
- * @module
10
- */
11
- // ── Regex patterns ─────────────────────────────────────────────────────────
12
- /** Matches Prisma client method calls: `prisma.user.findMany(...)` */
13
- const PRISMA_QUERY_RE = /\bprisma\.(\w+)\.(findMany|findFirst|findUnique|findUniqueOrThrow|findFirstOrThrow|create|createMany|update|updateMany|delete|deleteMany|upsert|count|aggregate|groupBy)\s*\(/g;
14
- /** Matches Supabase client method calls: `supabase.from('users').select(...)` */
15
- const SUPABASE_QUERY_RE = /\bsupabase\.from\s*\(\s*['"](\w+)['"]\s*\)\s*\.(select|insert|update|delete|upsert)\s*\(/g;
16
- // ── Extraction function ───────────────────────────────────────────────────
17
- /**
18
- * Extract ORM query calls from file content using regex.
19
- *
20
- * Fast-path: skips files that don't contain `prisma.` or `supabase.from`.
21
- * Results are appended to the `out` array (push pattern avoids allocation).
22
- *
23
- * @param filePath Relative path of the source file
24
- * @param content File content string
25
- * @param out Output array to append extracted queries to
26
- */
27
- export function extractORMQueriesInline(filePath, content, out) {
28
- const hasPrisma = content.includes('prisma.');
29
- const hasSupabase = content.includes('supabase.from');
30
- if (!hasPrisma && !hasSupabase)
31
- return;
32
- // Pre-compute line number offsets to avoid O(n²) substring+split per match
33
- const lineOffsets = buildLineOffsets(content);
34
- if (hasPrisma) {
35
- PRISMA_QUERY_RE.lastIndex = 0;
36
- let m;
37
- while ((m = PRISMA_QUERY_RE.exec(content)) !== null) {
38
- const model = m[1];
39
- if (model.startsWith('$'))
40
- continue;
41
- out.push({
42
- filePath,
43
- orm: 'prisma',
44
- model,
45
- method: m[2],
46
- lineNumber: lineNumberAtOffset(lineOffsets, m.index),
47
- });
48
- }
49
- }
50
- if (hasSupabase) {
51
- SUPABASE_QUERY_RE.lastIndex = 0;
52
- let m;
53
- while ((m = SUPABASE_QUERY_RE.exec(content)) !== null) {
54
- out.push({
55
- filePath,
56
- orm: 'supabase',
57
- model: m[1],
58
- method: m[2],
59
- lineNumber: lineNumberAtOffset(lineOffsets, m.index),
60
- });
61
- }
62
- }
63
- }
64
- // ── Line offset helpers ───────────────────────────────────────────────────
65
- /** Build an array of byte offsets where each newline occurs (O(n) once). */
66
- function buildLineOffsets(content) {
67
- const offsets = [];
68
- for (let i = 0; i < content.length; i++) {
69
- if (content[i] === '\n')
70
- offsets.push(i);
71
- }
72
- return offsets;
73
- }
74
- /**
75
- * Binary search for 0-based line number at a given character offset.
76
- *
77
- * Returns the number of newlines that occur before `offset` in the content,
78
- * which is the 0-based line number. When `offset` is beyond the last newline,
79
- * returns `lineOffsets.length` (i.e., the last line index).
80
- */
81
- function lineNumberAtOffset(lineOffsets, offset) {
82
- let lo = 0;
83
- let hi = lineOffsets.length;
84
- while (lo < hi) {
85
- const mid = (lo + hi) >>> 1;
86
- if (lineOffsets[mid] < offset)
87
- lo = mid + 1;
88
- else
89
- hi = mid;
90
- }
91
- return lo;
92
- }