gitnexus 1.6.10-rc.220 → 1.6.10-rc.222

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.
@@ -3,6 +3,8 @@ import type { Path } from 'path-scurry';
3
3
  export declare const shouldIgnorePath: (filePath: string) => boolean;
4
4
  /** Check if a directory name is in the hardcoded ignore list */
5
5
  export declare const isHardcodedIgnoredDirectory: (name: string) => boolean;
6
+ /** Apply directory ignore rules that depend on repository-relative depth. */
7
+ export declare const isHardcodedIgnoredDirectoryAtPath: (repoRoot: string, directoryPath: string) => boolean;
6
8
  /**
7
9
  * Load .gitignore and .gitnexusignore rules from the repo root.
8
10
  * Returns an `ignore` instance with all patterns, or null if no files found.
@@ -1,4 +1,5 @@
1
1
  import ignore from 'ignore';
2
+ import { existsSync } from 'fs';
2
3
  import fs from 'fs/promises';
3
4
  import nodePath from 'path';
4
5
  import { logger } from '../core/logger.js';
@@ -27,12 +28,15 @@ const DEFAULT_IGNORE_LIST = new Set([
27
28
  // 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
28
29
  'venv',
29
30
  '.venv',
30
- 'env',
31
31
  '.env',
32
+ // Bare `env/` can be application source or a Python virtual environment.
33
+ // Path-aware rules below prune it at the root and wherever pyvenv.cfg marks
34
+ // a virtual environment, while preserving ordinary nested source folders.
32
35
  '__pycache__',
33
36
  '.pytest_cache',
34
37
  '.mypy_cache',
35
38
  'site-packages',
39
+ 'dist-packages',
36
40
  '.tox',
37
41
  'eggs',
38
42
  '.eggs',
@@ -78,8 +82,9 @@ const DEFAULT_IGNORE_LIST = new Set([
78
82
  '.temp',
79
83
  // Generated/Compiled
80
84
  '.generated',
81
- 'generated',
82
85
  'auto-generated',
86
+ // Bare `generated/` can contain tracked source-of-truth code. Build output
87
+ // remains covered by .gitignore/.gitnexusignore and the unambiguous names.
83
88
  'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
84
89
  '.terraform',
85
90
  '.serverless',
@@ -95,6 +100,11 @@ const DEFAULT_IGNORE_LIST = new Set([
95
100
  'snapshots', // Jest snapshots
96
101
  '__snapshots__',
97
102
  ]);
103
+ // Ambiguous names that conventionally denote generated artifacts only at the
104
+ // repository root. Nested directories with these names are frequently source
105
+ // modules (for example apps/web/src/env or packages/api/generated).
106
+ const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']);
107
+ const isRootArtifactDirectory = (relativePath, name) => !relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name);
98
108
  const IGNORED_EXTENSIONS = new Set([
99
109
  // Images
100
110
  '.png',
@@ -264,6 +274,9 @@ export const shouldIgnorePath = (filePath) => {
264
274
  const parts = normalizedPath.split('/');
265
275
  const fileName = parts[parts.length - 1];
266
276
  const fileNameLower = fileName.toLowerCase();
277
+ if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) {
278
+ return true;
279
+ }
267
280
  // Laravel compiles Blade templates into generated PHP cache files under
268
281
  // storage/framework/views. Source templates live in resources/views and are
269
282
  // handled separately; compiled cache should not become source-of-truth. Keep
@@ -299,9 +312,7 @@ export const shouldIgnorePath = (filePath) => {
299
312
  // Ignore files that look like generated/bundled code
300
313
  if (fileNameLower.includes('.bundle.') ||
301
314
  fileNameLower.includes('.chunk.') ||
302
- fileNameLower.includes('.generated.') ||
303
- fileNameLower.endsWith('.d.ts')) {
304
- // TypeScript declaration files
315
+ fileNameLower.includes('.generated.')) {
305
316
  return true;
306
317
  }
307
318
  return false;
@@ -310,6 +321,16 @@ export const shouldIgnorePath = (filePath) => {
310
321
  export const isHardcodedIgnoredDirectory = (name) => {
311
322
  return DEFAULT_IGNORE_LIST.has(name);
312
323
  };
324
+ /** Apply directory ignore rules that depend on repository-relative depth. */
325
+ export const isHardcodedIgnoredDirectoryAtPath = (repoRoot, directoryPath) => {
326
+ const name = nodePath.basename(directoryPath);
327
+ if (isHardcodedIgnoredDirectory(name))
328
+ return true;
329
+ const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/');
330
+ if (isRootArtifactDirectory(relative, name))
331
+ return true;
332
+ return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg'));
333
+ };
313
334
  export const loadIgnoreRules = async (repoPath, options) => {
314
335
  const ig = ignore();
315
336
  let hasRules = false;
@@ -450,9 +471,10 @@ export const createIgnoreFilter = async (repoPath, options) => {
450
471
  // blocks descent into `__tests__/generated/`.
451
472
  if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/'))
452
473
  return false;
453
- // Hardcoded list: block descent into well-known noise directories.
454
- if (DEFAULT_IGNORE_LIST.has(p.name))
474
+ // Hardcoded and path-aware rules prune whole trees before glob walks them.
475
+ if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) {
455
476
  return true;
477
+ }
456
478
  // Check against .gitignore / .gitnexusignore patterns.
457
479
  // Since childrenIgnored is only called for directories, always test with
458
480
  // a trailing slash. This ensures directory-only negation patterns (e.g.
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
3
+ import { shouldIgnorePath, loadIgnoreRules, isHardcodedIgnoredDirectoryAtPath, } from '../../../config/ignore-service.js';
4
4
  import { logger } from '../../logger.js';
5
5
  async function parsePythonManifest(repoPath) {
6
6
  const pyprojectPath = path.join(repoPath, 'pyproject.toml');
@@ -124,11 +124,14 @@ async function findPythonFiles(repoPath) {
124
124
  for (const entry of entries) {
125
125
  const childRel = rel ? `${rel}/${entry.name}` : entry.name;
126
126
  if (entry.isDirectory()) {
127
+ const childPath = path.join(dir, entry.name);
127
128
  if (shouldIgnorePath(childRel))
128
129
  continue;
130
+ if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath))
131
+ continue;
129
132
  if (ig && ig.ignores(childRel + '/'))
130
133
  continue;
131
- await walk(path.join(dir, entry.name), childRel);
134
+ await walk(childPath, childRel);
132
135
  }
133
136
  else if (entry.name.endsWith('.py')) {
134
137
  if (shouldIgnorePath(childRel))
@@ -8,6 +8,20 @@ import { mapConcurrent } from '../../lib/utils.js';
8
8
  import { logger } from '../logger.js';
9
9
  const READ_CONCURRENCY = 32;
10
10
  const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
11
+ const DECLARATION_COMPANION_SUFFIXES = [
12
+ { declaration: '.d.ts', implementations: ['.ts', '.tsx'] },
13
+ { declaration: '.d.mts', implementations: ['.mts'] },
14
+ { declaration: '.d.cts', implementations: ['.cts'] },
15
+ ];
16
+ const hasImplementationSibling = (declarationPath, scannedPaths) => {
17
+ const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) => declarationPath.endsWith(declaration));
18
+ if (!companion)
19
+ return false;
20
+ // Keep standalone declarations. Only suppress declaration output that sits
21
+ // beside an implementation with the corresponding module suffix.
22
+ const stem = declarationPath.slice(0, -companion.declaration.length);
23
+ return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`));
24
+ };
11
25
  const warnLargeFileSkip = (message) => {
12
26
  if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
13
27
  // analyze.ts routes console.warn through the progress bar logger while
@@ -61,10 +75,12 @@ export const walkRepositoryPaths = async (repoPath, onProgress) => {
61
75
  }
62
76
  }
63
77
  }
78
+ const scannedPaths = new Set(entries.map((entry) => entry.path));
79
+ const deduplicatedEntries = entries.filter((entry) => !hasImplementationSibling(entry.path, scannedPaths));
64
80
  // Filesystem/glob traversal order is not stable across filesystems or repeated
65
81
  // scans. Canonicalize once at the scan boundary so every downstream phase sees
66
82
  // the same repository order.
67
- entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
83
+ deduplicatedEntries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
68
84
  if (skippedLarge > 0) {
69
85
  const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
70
86
  const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
@@ -93,7 +109,7 @@ export const walkRepositoryPaths = async (repoPath, onProgress) => {
93
109
  warnLargeFileSkip(` Set GITNEXUS_MAX_FILE_SIZE=<KB> to include files above the default cap.`);
94
110
  }
95
111
  }
96
- return entries;
112
+ return deduplicatedEntries;
97
113
  };
98
114
  /**
99
115
  * Phase 2: Read file contents for a specific set of relative paths.
@@ -17,7 +17,7 @@
17
17
  import fs from 'fs/promises';
18
18
  import path from 'path';
19
19
  import { createRequire } from 'node:module';
20
- import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js';
20
+ import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js';
21
21
  import { logger } from '../../logger.js';
22
22
  import { resolveFile } from '../languages/typescript/file-candidates.js';
23
23
  // `js-yaml` is CJS; the rest of this repository reaches it the same way
@@ -285,10 +285,11 @@ export async function loadNodeWorkspacePackages(repoRoot) {
285
285
  }
286
286
  for (const entry of entries) {
287
287
  if (entry.isDirectory()) {
288
- if (isHardcodedIgnoredDirectory(entry.name))
288
+ const childDir = path.join(dir, entry.name);
289
+ if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir))
289
290
  continue;
290
291
  if (depth < SCAN_MAX_DEPTH) {
291
- queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
292
+ queue.push({ dir: childDir, depth: depth + 1 });
292
293
  }
293
294
  continue;
294
295
  }
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import fs from 'fs/promises';
23
23
  import path from 'path';
24
- import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js';
24
+ import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js';
25
25
  import { logger } from '../../../logger.js';
26
26
  const SCAN_MAX_DIRS = 20_000;
27
27
  const SCAN_MAX_DEPTH = 24;
@@ -247,10 +247,11 @@ async function findTsconfigFiles(repoRoot) {
247
247
  }
248
248
  for (const entry of entries) {
249
249
  if (entry.isDirectory()) {
250
- if (isHardcodedIgnoredDirectory(entry.name))
250
+ const childDir = path.join(dir, entry.name);
251
+ if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir))
251
252
  continue;
252
253
  if (depth < SCAN_MAX_DEPTH)
253
- queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
254
+ queue.push({ dir: childDir, depth: depth + 1 });
254
255
  continue;
255
256
  }
256
257
  if (!entry.isFile())
@@ -122,6 +122,15 @@ export declare const initLbug: (repoId: string, dbPath: string) => Promise<boole
122
122
  * repoId already injected it), the existing entry is reused.
123
123
  */
124
124
  export declare function initLbugWithDb(repoId: string, existingDb: lbug.Database, dbPath: string): Promise<void>;
125
+ /**
126
+ * Lazily load VECTOR for a semantic query.
127
+ *
128
+ * Exact graph reads never call this function, so opening their read pool does
129
+ * not probe or warn about an optional extension they do not use. The promise
130
+ * lives on SharedDB because extension scope is per Database, and also joins
131
+ * concurrent first semantic requests onto one LOAD attempt.
132
+ */
133
+ export declare function ensureVectorExtension(repoId: string): Promise<boolean>;
125
134
  export declare const executeQuery: (repoId: string, cypher: string) => Promise<any[]>;
126
135
  /**
127
136
  * Execute a parameterized query on a specific repo's connection pool.
@@ -265,6 +265,7 @@ function closeOne(repoId) {
265
265
  shared.refCount = 0;
266
266
  shared.ftsLoaded = false;
267
267
  shared.vectorLoaded = false;
268
+ shared.vectorLoadPromise = undefined;
268
269
  }
269
270
  else {
270
271
  shared.db.close().catch(() => { });
@@ -692,13 +693,6 @@ async function doInitLbug(repoId, dbPath) {
692
693
  if (!shared.ftsLoaded) {
693
694
  shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
694
695
  }
695
- // VECTOR too — extension load scope is per-Database, so this one load
696
- // makes QUERY_VECTOR_INDEX legal on every pooled connection. Same
697
- // load-only contract as FTS above; on failure the semantic-query lane
698
- // falls back to the exact scan with its own diagnostic (#2623 follow-up).
699
- if (!shared.vectorLoaded) {
700
- shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
701
- }
702
696
  // Register pool entry only after all connections are pre-warmed and FTS is
703
697
  // loaded. Concurrent executeQuery calls see either "not initialized"
704
698
  // (and throw cleanly) or a fully ready pool — never a half-built one.
@@ -761,11 +755,6 @@ export async function initLbugWithDb(repoId, existingDb, dbPath) {
761
755
  if (!shared.ftsLoaded) {
762
756
  shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
763
757
  }
764
- // VECTOR too — same per-Database scope and load-only contract as the
765
- // doInitLbug site above (#2623 follow-up).
766
- if (!shared.vectorLoaded) {
767
- shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
768
- }
769
758
  pool.set(repoId, {
770
759
  db: existingDb,
771
760
  available,
@@ -780,6 +769,143 @@ export async function initLbugWithDb(repoId, existingDb, dbPath) {
780
769
  ensureIdleTimer();
781
770
  traceRss('init', repoId);
782
771
  }
772
+ /**
773
+ * Lazily load VECTOR for a semantic query.
774
+ *
775
+ * Exact graph reads never call this function, so opening their read pool does
776
+ * not probe or warn about an optional extension they do not use. The promise
777
+ * lives on SharedDB because extension scope is per Database, and also joins
778
+ * concurrent first semantic requests onto one LOAD attempt.
779
+ */
780
+ export async function ensureVectorExtension(repoId) {
781
+ const entry = pool.get(repoId);
782
+ if (!entry) {
783
+ throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
784
+ }
785
+ const shared = dbCache.get(entry.dbPath);
786
+ if (!shared) {
787
+ throw new Error(`LadybugDB shared handle is unavailable for repo "${repoId}".`);
788
+ }
789
+ if (shared.vectorLoaded)
790
+ return true;
791
+ if (shared.vectorLoadPromise)
792
+ return shared.vectorLoadPromise;
793
+ const loadAttempt = (async () => {
794
+ const conn = await checkout(entry);
795
+ try {
796
+ const loaded = await loadVectorExtension(conn, { policy: 'load-only' });
797
+ shared.vectorLoaded = loaded;
798
+ return loaded;
799
+ }
800
+ finally {
801
+ checkin(entry, conn);
802
+ }
803
+ })();
804
+ const cachedAttempt = loadAttempt.catch((err) => {
805
+ // A transient checkout/load failure must not poison this Database for the
806
+ // rest of its lifetime. Keep resolved false cached, but let a later
807
+ // semantic request retry a rejected attempt.
808
+ if (shared.vectorLoadPromise === cachedAttempt) {
809
+ shared.vectorLoadPromise = undefined;
810
+ }
811
+ throw err;
812
+ });
813
+ shared.vectorLoadPromise = cachedAttempt;
814
+ return shared.vectorLoadPromise;
815
+ }
816
+ /**
817
+ * Detect an actual VECTOR procedure call without treating source text stored in
818
+ * Cypher literals or comments as executable syntax.
819
+ */
820
+ function callsVectorIndex(cypher) {
821
+ if (!/QUERY_VECTOR_INDEX/i.test(cypher))
822
+ return false;
823
+ let code = '';
824
+ let state = 'code';
825
+ let backtickIdentifier = '';
826
+ for (let i = 0; i < cypher.length; i++) {
827
+ const ch = cypher[i];
828
+ const next = cypher[i + 1];
829
+ if (state === 'code') {
830
+ if (ch === "'" || ch === '"' || ch === '`') {
831
+ state = ch === "'" ? 'single' : ch === '"' ? 'double' : 'backtick';
832
+ if (state === 'backtick')
833
+ backtickIdentifier = '';
834
+ code += ' ';
835
+ }
836
+ else if (ch === '/' && next === '/') {
837
+ state = 'line-comment';
838
+ code += ' ';
839
+ i++;
840
+ }
841
+ else if (ch === '/' && next === '*') {
842
+ state = 'block-comment';
843
+ code += ' ';
844
+ i++;
845
+ }
846
+ else {
847
+ code += ch;
848
+ }
849
+ continue;
850
+ }
851
+ if (state === 'line-comment') {
852
+ if (ch === '\n' || ch === '\r') {
853
+ state = 'code';
854
+ code += ch;
855
+ }
856
+ else {
857
+ code += ' ';
858
+ }
859
+ continue;
860
+ }
861
+ if (state === 'block-comment') {
862
+ if (ch === '*' && next === '/') {
863
+ state = 'code';
864
+ code += ' ';
865
+ i++;
866
+ }
867
+ else {
868
+ code += ch === '\n' || ch === '\r' ? ch : ' ';
869
+ }
870
+ continue;
871
+ }
872
+ if (state === 'backtick') {
873
+ if (ch === '`' && next === '`') {
874
+ backtickIdentifier += '`';
875
+ code += ' ';
876
+ i++;
877
+ }
878
+ else if (ch === '`') {
879
+ state = 'code';
880
+ code +=
881
+ backtickIdentifier.toUpperCase() === 'QUERY_VECTOR_INDEX' ? 'QUERY_VECTOR_INDEX' : ' ';
882
+ }
883
+ else if (ch === '\\' && next !== undefined) {
884
+ backtickIdentifier += next;
885
+ code += ' ';
886
+ i++;
887
+ }
888
+ else {
889
+ backtickIdentifier += ch;
890
+ code += ch === '\n' || ch === '\r' ? ch : ' ';
891
+ }
892
+ continue;
893
+ }
894
+ if (ch === '\\') {
895
+ code += ' ';
896
+ if (next !== undefined) {
897
+ code += next === '\n' || next === '\r' ? next : ' ';
898
+ i++;
899
+ }
900
+ continue;
901
+ }
902
+ const closesLiteral = (state === 'single' && ch === "'") || (state === 'double' && ch === '"');
903
+ if (closesLiteral)
904
+ state = 'code';
905
+ code += ch === '\n' || ch === '\r' ? ch : ' ';
906
+ }
907
+ return /\bCALL\s+QUERY_VECTOR_INDEX\s*\(/i.test(code);
908
+ }
783
909
  /**
784
910
  * Checkout a connection from the pool.
785
911
  * Returns an available connection, or creates a new one if under the cap.
@@ -870,12 +996,26 @@ export const executeParameterized = async (repoId, cypher, params) => {
870
996
  // a query built by splicing a caller-sized list names itself even when the
871
997
  // repo is not initialized. Never throws (#2915).
872
998
  warnIfQueryTextUnbounded(cypher, `pool executeParameterized (repo "${repoId}")`, (message) => poolSidecarLogger.warn(message));
873
- const entry = pool.get(repoId);
999
+ let entry = pool.get(repoId);
874
1000
  if (!entry) {
875
1001
  throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
876
1002
  }
877
- entry.lastUsed = Date.now();
1003
+ // Exact reads must not pay for VECTOR, but an explicit raw vector procedure
1004
+ // call is a semantic read. Preflight before taking the query connection:
1005
+ // ensureVectorExtension performs its own checkout, so holding one here could
1006
+ // make a saturated pool wait for a connection that every caller is holding.
1007
+ // A load rejection must not replace the query's own diagnostic.
1008
+ if (callsVectorIndex(cypher)) {
1009
+ await ensureVectorExtension(repoId).catch(() => false);
1010
+ // The preflight suspends, so close/re-init may replace the pool entry.
1011
+ // Re-read it before checkout to avoid querying through a stale handle.
1012
+ entry = pool.get(repoId);
1013
+ if (!entry) {
1014
+ throw new Error(`LadybugDB connection pool closed for repo "${repoId}" (re-init/teardown); retry the query.`);
1015
+ }
1016
+ }
878
1017
  const conn = await checkout(entry);
1018
+ entry.lastUsed = Date.now();
879
1019
  silenceStdout();
880
1020
  activeQueryCount++;
881
1021
  let queryResult;
@@ -256,12 +256,12 @@ export declare class LocalBackend {
256
256
  */
257
257
  private warnedSiblingDrift;
258
258
  /**
259
- * One-shot stderr warning for the VECTOR-extension fallback. Without this
260
- * guard the diagnostic would fire on every `semanticSearch()` call on
261
- * platforms where the extension is unsupported (e.g. Windows), making MCP
262
- * stderr noisy per DoD §2.8.
259
+ * One-shot stderr guards for distinct VECTOR load and index-query failures.
260
+ * Keeping them separate preserves both diagnostics across semanticSearch calls
261
+ * without repeating either on hot paths.
263
262
  */
264
- private warnedVectorUnsupported;
263
+ private warnedVectorLoadFailed;
264
+ private warnedVectorQueryFailed;
265
265
  /**
266
266
  * One-shot warning when a pruned or Node-unloadable optional embedding stack
267
267
  * (#2370/#2372) forces semantic search to fall back to BM25 — so the
@@ -8,7 +8,7 @@
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { createHash } from 'crypto';
11
- import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, statDbIdentity, dbIdentityChanged, } from '../../core/lbug/pool-adapter.js';
11
+ import { initLbug, executeQuery, executeParameterized, ensureVectorExtension, closeLbug, isLbugReady, statDbIdentity, dbIdentityChanged, } from '../../core/lbug/pool-adapter.js';
12
12
  import { queryClassBeanMetadata } from './bean-metadata.js';
13
13
  import { querySpringAopMetadata } from './aop-metadata.js';
14
14
  import { queryConvexDispatchMetadata } from './convex-metadata.js';
@@ -783,12 +783,12 @@ export class LocalBackend {
783
783
  */
784
784
  warnedSiblingDrift = new Set();
785
785
  /**
786
- * One-shot stderr warning for the VECTOR-extension fallback. Without this
787
- * guard the diagnostic would fire on every `semanticSearch()` call on
788
- * platforms where the extension is unsupported (e.g. Windows), making MCP
789
- * stderr noisy per DoD §2.8.
786
+ * One-shot stderr guards for distinct VECTOR load and index-query failures.
787
+ * Keeping them separate preserves both diagnostics across semanticSearch calls
788
+ * without repeating either on hot paths.
790
789
  */
791
- warnedVectorUnsupported = false;
790
+ warnedVectorLoadFailed = false;
791
+ warnedVectorQueryFailed = false;
792
792
  /**
793
793
  * One-shot warning when a pruned or Node-unloadable optional embedding stack
794
794
  * (#2370/#2372) forces semantic search to fall back to BM25 — so the
@@ -2518,17 +2518,25 @@ export class LocalBackend {
2518
2518
  this.lastQueryEmbeddingDims.set(repo.lbugPath, dims);
2519
2519
  const queryVecStr = `[${queryVec.join(',')}]`;
2520
2520
  const maxDistance = getVectorMaxDistance(DEFAULT_MCP_VECTOR_MAX_DISTANCE);
2521
- let bestChunks = new Map();
2522
- // Always TRY the vector lane — no platform gate. LadybugDB ships the
2523
- // VECTOR extension for every supported platform, Windows included
2524
- // (#2623 follow-up; the old `platform !== 'win32'` gate was stale), so
2525
- // whether the index is queryable is a per-machine runtime fact. The
2526
- // catch below is the fallback: any failure (extension unloadable, index
2527
- // absent, older DB) degrades to the exact scan with a once-per-backend
2528
- // diagnostic instead of being silently swallowed.
2521
+ let vectorReady = false;
2529
2522
  try {
2530
- bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
2531
- const vectorQuery = `
2523
+ vectorReady = await ensureVectorExtension(repo.lbugPath);
2524
+ }
2525
+ catch (err) {
2526
+ if (!this.warnedVectorLoadFailed) {
2527
+ this.warnedVectorLoadFailed = true;
2528
+ logger.warn({ err }, 'GitNexus [query:vector]: vector extension load failed; using exact scan fallback');
2529
+ }
2530
+ }
2531
+ let bestChunks = new Map();
2532
+ // Try the vector lane only after its lazy load succeeds. An unavailable
2533
+ // extension is already reported by ExtensionManager; an index/query
2534
+ // failure below gets its own once-per-backend diagnostic before the exact
2535
+ // scan fallback.
2536
+ if (vectorReady) {
2537
+ try {
2538
+ bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
2539
+ const vectorQuery = `
2532
2540
  CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
2533
2541
  CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit})
2534
2542
  YIELD node AS emb, distance
@@ -2538,24 +2546,25 @@ export class LocalBackend {
2538
2546
  emb.startLine AS startLine, emb.endLine AS endLine, distance
2539
2547
  ORDER BY distance
2540
2548
  `;
2541
- const embResults = await executeQuery(repo.lbugPath, vectorQuery);
2542
- return embResults.map((row) => ({
2543
- nodeId: row.nodeId ?? row[0],
2544
- chunkIndex: row.chunkIndex ?? row[1] ?? 0,
2545
- startLine: row.startLine ?? row[2] ?? 0,
2546
- endLine: row.endLine ?? row[3] ?? 0,
2547
- distance: row.distance ?? row[4],
2548
- }));
2549
- });
2550
- }
2551
- catch (err) {
2552
- bestChunks = new Map();
2553
- if (!this.warnedVectorUnsupported) {
2554
- // Rare diagnostic: surface why semantic search fell back to the
2555
- // exact scan. Emitted once per `LocalBackend` instance lifetime to
2556
- // avoid noisy stderr on hot semantic-search paths (DoD §2.8).
2557
- this.warnedVectorUnsupported = true;
2558
- logger.warn({ err }, 'GitNexus [query:vector]: vector index query failed; using exact scan fallback');
2549
+ const embResults = await executeQuery(repo.lbugPath, vectorQuery);
2550
+ return embResults.map((row) => ({
2551
+ nodeId: row.nodeId ?? row[0],
2552
+ chunkIndex: row.chunkIndex ?? row[1] ?? 0,
2553
+ startLine: row.startLine ?? row[2] ?? 0,
2554
+ endLine: row.endLine ?? row[3] ?? 0,
2555
+ distance: row.distance ?? row[4],
2556
+ }));
2557
+ });
2558
+ }
2559
+ catch (err) {
2560
+ bestChunks = new Map();
2561
+ if (!this.warnedVectorQueryFailed) {
2562
+ // Rare diagnostic: surface why semantic search fell back to the
2563
+ // exact scan. Emitted once per `LocalBackend` instance lifetime to
2564
+ // avoid noisy stderr on hot semantic-search paths (DoD §2.8).
2565
+ this.warnedVectorQueryFailed = true;
2566
+ logger.warn({ err }, 'GitNexus [query:vector]: vector index query failed; using exact scan fallback');
2567
+ }
2559
2568
  }
2560
2569
  }
2561
2570
  if (bestChunks.size === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.220",
3
+ "version": "1.6.10-rc.222",
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",