gitnexus 1.6.7-rc.5 → 1.6.7-rc.7

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.
@@ -39,6 +39,17 @@ const MAX_POOL_SIZE = 5;
39
39
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
40
40
  /** Max connections per repo (caps concurrent queries per repo) */
41
41
  const MAX_CONNS_PER_REPO = 8;
42
+ // Behavior-neutral RSS tracing for the FTS evict→reload memory repro
43
+ // (gitnexus/scripts/bench/fts-evict-reload-rss.mjs). Two invariants keep it safe
44
+ // in the pool init/close hot path: it writes ONLY to stderr (stdout is the MCP
45
+ // JSON-RPC channel), and the GITNEXUS_POOL_RSS_TRACE gate makes it a no-op — one
46
+ // env-var compare per call, nothing else — unless a harness explicitly enables it.
47
+ function traceRss(event, repoId) {
48
+ if (process.env.GITNEXUS_POOL_RSS_TRACE !== '1')
49
+ return;
50
+ const rssMb = Math.round(process.memoryUsage().rss / (1024 * 1024));
51
+ process.stderr.write(`[pool-rss] ${event} repo=${repoId} pool=${pool.size} dbCache=${dbCache.size} rssMB=${rssMb}\n`);
52
+ }
42
53
  let idleTimer = null;
43
54
  // Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
44
55
  // module with zero non-`node:` imports. We re-export the same symbols here
@@ -166,6 +177,7 @@ function closeOne(repoId) {
166
177
  // Isolate listener failures — teardown must complete.
167
178
  }
168
179
  }
180
+ traceRss('close', repoId);
169
181
  }
170
182
  /**
171
183
  * Create a new Connection from a repo's Database.
@@ -511,6 +523,7 @@ async function doInitLbug(repoId, dbPath) {
511
523
  closed: false,
512
524
  });
513
525
  ensureIdleTimer();
526
+ traceRss('init', repoId);
514
527
  }
515
528
  /**
516
529
  * Initialize a pool entry from a pre-existing Database object.
@@ -565,6 +578,7 @@ export async function initLbugWithDb(repoId, existingDb, dbPath) {
565
578
  closed: false,
566
579
  });
567
580
  ensureIdleTimer();
581
+ traceRss('init', repoId);
568
582
  }
569
583
  /**
570
584
  * Checkout a connection from the pool.
@@ -143,6 +143,18 @@ 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
+ /**
147
+ * A "missing table/label/relation" prepare error is benign for the query tool's
148
+ * best-effort enrichment: a repo analyzed without processes or communities simply
149
+ * has no `Process`/`Community` tables, so the `STEP_IN_PROCESS` / `MEMBER_OF`
150
+ * enrichment queries fail to prepare. That is a normal configuration, NOT a
151
+ * degraded result — it must not raise the `partial` flag (which callers would
152
+ * then learn to ignore). Real failures (timeouts, locks, native faults) do.
153
+ */
154
+ function isBenignMissingTableError(err) {
155
+ const msg = err instanceof Error ? err.message : String(err ?? '');
156
+ return /does not exist|no such (table|label|rel)|unknown (table|label)|not (defined|found)/i.test(msg);
157
+ }
146
158
  const isReadOnlyDbError = (err) => {
147
159
  // Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
148
160
  // pool adapter's `{ cause }` wrapper) is still detected here — this is the
@@ -925,61 +937,117 @@ export class LocalBackend {
925
937
  timer.start('symbol_lookup');
926
938
  const processMap = new Map();
927
939
  const definitions = []; // standalone symbols not in any process
928
- for (const [_, item] of merged) {
929
- const sym = item.data;
930
- if (!sym.nodeId) {
931
- // File-level results go to definitions
932
- definitions.push({
933
- name: sym.name,
934
- type: sym.type || 'File',
935
- filePath: sym.filePath,
936
- });
937
- continue;
938
- }
939
- // Find processes this symbol participates in
940
- let processRows = [];
940
+ // Batch-fetch process participation, cohesion, and (optionally) content for
941
+ // ALL matched symbols in 2-3 graph queries instead of 2-3 *per symbol*. The
942
+ // previous per-symbol loop issued up to 3N sequential pool round-trips
943
+ // (searchLimit symbols × {STEP_IN_PROCESS, MEMBER_OF, content}); on a warm
944
+ // repo the IPC + query-setup overhead of those round-trips dominated query
945
+ // latency. Collapsing to `WHERE n.id IN $nodeIds` preserves identical output
946
+ // (the aggregation loop below is unchanged) while cutting the round-trips.
947
+ // Array params bind through the pool exactly as bm25Search's
948
+ // `WHERE n.id IN $nodeIds` already does. (Ported from gitnexus-enterprise
949
+ // PR #222 — N+1 → 2-3 batched queries.)
950
+ const nodeIds = merged.map(([, m]) => m.data?.nodeId).filter((id) => !!id);
951
+ const processRowsByNode = new Map();
952
+ const cohesionByNode = new Map();
953
+ const contentByNode = new Map();
954
+ // Set when a batched enrichment query throws a REAL failure (timeout, lock,
955
+ // native fault) — NOT the benign "no Process/Community table" case, which is
956
+ // a normal config (a repo analyzed without processes/communities) and must
957
+ // not raise a `partial` flag callers would learn to ignore. See
958
+ // isBenignMissingTableError + the response build below.
959
+ let enrichmentDegraded = false;
960
+ // Chunk the IN-list like the impact path (CHUNK_SIZE=100) so a large result
961
+ // set never builds an unbounded `IN` parameter. Default batch is
962
+ // processLimit*maxSymbolsPerProcess (≤ one chunk), but chunk for robustness.
963
+ const QUERY_CHUNK_SIZE = 100;
964
+ for (let i = 0; i < nodeIds.length; i += QUERY_CHUNK_SIZE) {
965
+ const ids = nodeIds.slice(i, i + QUERY_CHUNK_SIZE);
966
+ // Processes each symbol participates in. `n.id AS nodeId` is prepended as
967
+ // column 0 so rows from many symbols can be re-associated to their symbol.
941
968
  try {
942
- processRows = await executeParameterized(repo.lbugPath, `
943
- MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
944
- RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
945
- `, { nodeId: sym.nodeId });
969
+ const rows = await executeParameterized(repo.lbugPath, `
970
+ MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
971
+ WHERE n.id IN $nodeIds
972
+ RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
973
+ `, { nodeIds: ids });
974
+ for (const row of rows) {
975
+ const nid = row.nodeId ?? row[0];
976
+ let list = processRowsByNode.get(nid);
977
+ if (!list)
978
+ processRowsByNode.set(nid, (list = []));
979
+ list.push(row);
980
+ }
946
981
  }
947
982
  catch (e) {
948
983
  logQueryError('query:process-lookup', e);
984
+ if (!isBenignMissingTableError(e))
985
+ enrichmentDegraded = true;
949
986
  }
950
- // Get cluster membership + cohesion (cohesion used as internal ranking signal)
951
- let cohesion = 0;
952
- let module;
987
+ // Cluster membership + cohesion. Keep the FIRST community row per node to
988
+ // mirror the prior per-symbol `LIMIT 1` (each symbol keeps ITS community,
989
+ // not one community for the whole batch).
953
990
  try {
954
- const cohesionRows = await executeParameterized(repo.lbugPath, `
955
- MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
956
- RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
957
- LIMIT 1
958
- `, { nodeId: sym.nodeId });
959
- if (cohesionRows.length > 0) {
960
- cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
961
- module = cohesionRows[0].module ?? cohesionRows[0][1];
991
+ const rows = await executeParameterized(repo.lbugPath, `
992
+ MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
993
+ WHERE n.id IN $nodeIds
994
+ RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
995
+ `, { nodeIds: ids });
996
+ for (const row of rows) {
997
+ const nid = row.nodeId ?? row[0];
998
+ if (!cohesionByNode.has(nid)) {
999
+ cohesionByNode.set(nid, {
1000
+ cohesion: (row.cohesion ?? row[1]) || 0,
1001
+ module: row.module ?? row[2],
1002
+ });
1003
+ }
962
1004
  }
963
1005
  }
964
1006
  catch (e) {
965
1007
  logQueryError('query:cluster-info', e);
1008
+ if (!isBenignMissingTableError(e))
1009
+ enrichmentDegraded = true;
966
1010
  }
967
- // Optionally fetch content
968
- let content;
1011
+ // Optionally fetch content for every matched symbol.
969
1012
  if (includeContent) {
970
1013
  try {
971
- const contentRows = await executeParameterized(repo.lbugPath, `
972
- MATCH (n {id: $nodeId})
973
- RETURN n.content AS content
974
- `, { nodeId: sym.nodeId });
975
- if (contentRows.length > 0) {
976
- content = contentRows[0].content ?? contentRows[0][0];
1014
+ const rows = await executeParameterized(repo.lbugPath, `
1015
+ MATCH (n)
1016
+ WHERE n.id IN $nodeIds
1017
+ RETURN n.id AS nodeId, n.content AS content
1018
+ `, { nodeIds: ids });
1019
+ for (const row of rows) {
1020
+ const nid = row.nodeId ?? row[0];
1021
+ contentByNode.set(nid, row.content ?? row[1]);
977
1022
  }
978
1023
  }
979
1024
  catch (e) {
980
1025
  logQueryError('query:content-fetch', e);
1026
+ if (!isBenignMissingTableError(e))
1027
+ enrichmentDegraded = true;
981
1028
  }
982
1029
  }
1030
+ }
1031
+ // Aggregation is unchanged from the per-symbol version — it now reads the
1032
+ // pre-fetched maps instead of issuing a query per symbol. Iterating `merged`
1033
+ // in the same (sorted) order preserves processMap insertion order, the
1034
+ // definitions order, and the item.score association exactly.
1035
+ for (const [_, item] of merged) {
1036
+ const sym = item.data;
1037
+ if (!sym.nodeId) {
1038
+ // File-level results go to definitions
1039
+ definitions.push({
1040
+ name: sym.name,
1041
+ type: sym.type || 'File',
1042
+ filePath: sym.filePath,
1043
+ });
1044
+ continue;
1045
+ }
1046
+ const processRows = processRowsByNode.get(sym.nodeId) ?? [];
1047
+ const coh = cohesionByNode.get(sym.nodeId);
1048
+ const cohesion = coh?.cohesion ?? 0;
1049
+ const module = coh?.module;
1050
+ const content = includeContent ? contentByNode.get(sym.nodeId) : undefined;
983
1051
  const symbolEntry = {
984
1052
  id: sym.nodeId,
985
1053
  name: sym.name,
@@ -997,12 +1065,13 @@ export class LocalBackend {
997
1065
  else {
998
1066
  // Add to each process it belongs to
999
1067
  for (const row of processRows) {
1000
- const pid = row.pid ?? row[0];
1001
- const label = row.label ?? row[1];
1002
- const hLabel = row.heuristicLabel ?? row[2];
1003
- const pType = row.processType ?? row[3];
1004
- const stepCount = row.stepCount ?? row[4];
1005
- const step = row.step ?? row[5];
1068
+ // Positional fallbacks shift +1 because `n.id AS nodeId` is column 0.
1069
+ const pid = row.pid ?? row[1];
1070
+ const label = row.label ?? row[2];
1071
+ const hLabel = row.heuristicLabel ?? row[3];
1072
+ const pType = row.processType ?? row[4];
1073
+ const stepCount = row.stepCount ?? row[5];
1074
+ const step = row.step ?? row[6];
1006
1075
  if (!processMap.has(pid)) {
1007
1076
  processMap.set(pid, {
1008
1077
  id: pid,
@@ -1066,14 +1135,24 @@ export class LocalBackend {
1066
1135
  timer.mark('wall', performance.now() - wallStart);
1067
1136
  const timing = timer.summary();
1068
1137
  logQueryTiming(searchQuery, timing);
1138
+ // Compose a single `warning` from all degraded conditions (FTS-missing
1139
+ // and/or a real enrichment failure) so neither overwrites the other, and
1140
+ // flag `partial` when enrichment was lost. Both are omitted on the clean
1141
+ // path, leaving the success-path response shape byte-identical.
1142
+ const warnings = [];
1143
+ if (!ftsUsed) {
1144
+ warnings.push('FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.');
1145
+ }
1146
+ if (enrichmentDegraded) {
1147
+ warnings.push('Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).');
1148
+ }
1069
1149
  return {
1070
1150
  processes,
1071
1151
  process_symbols: dedupedSymbols,
1072
1152
  definitions: definitions.slice(0, 20), // cap standalone definitions
1073
1153
  timing,
1074
- ...(!ftsUsed && {
1075
- warning: 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --repair-fts (or gitnexus analyze --force) to rebuild indexes.',
1076
- }),
1154
+ ...(warnings.length > 0 && { warning: warnings.join(' ') }),
1155
+ ...(enrichmentDegraded && { partial: true }),
1077
1156
  };
1078
1157
  }
1079
1158
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.7-rc.5",
3
+ "version": "1.6.7-rc.7",
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",
@@ -49,7 +49,7 @@
49
49
  "test:watch": "vitest",
50
50
  "test:coverage": "vitest run --coverage",
51
51
  "test:cross-platform": "tsx scripts/run-cross-platform.ts",
52
- "postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs",
52
+ "postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs && node scripts/build-tree-sitter-kotlin.cjs",
53
53
  "prepare": "node scripts/build.js",
54
54
  "prepack": "node scripts/build.js"
55
55
  },
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ // FTS evict→reload RSS repro (gitnexus-enterprise PR #222 / local U3).
3
+ //
4
+ // Settles ONE empirical question that no static read can answer: when a
5
+ // LadybugDB database that has `LOAD EXTENSION fts` applied is closed and a
6
+ // fresh one is opened + re-LOADed (the pool's evict→reload cycle), does the
7
+ // native FTS arena get reclaimed by `db.close()` — or is it stranded, so RSS
8
+ // climbs without bound over a long-lived MCP `serve` session?
9
+ //
10
+ // • PLATEAU across cycles → db.close() reclaims the FTS arena; the OSS pool's
11
+ // footprint is bounded by MAX_POOL_SIZE (~5 live arenas). No unbounded leak;
12
+ // the #222 worker-isolation rewrite (plan U4) is NOT justified for OSS.
13
+ // • MONOTONIC CLIMB → the FTS arena is stranded per reopen; the user's
14
+ // hypothesis holds and U4 (route FTS reads through a reclaimable worker) is
15
+ // justified.
16
+ //
17
+ // SCOPE OF THE VERDICT (read before citing it). A per-reload FTS-arena leak
18
+ // would be PROPORTIONAL to the index size. A small fixture therefore produces a
19
+ // small per-cycle increment that an absolute threshold can read as PLATEAU even
20
+ // when a production-scale graph would leak visibly. So:
21
+ // - `--rows` controls fixture size; run it LARGE (tens of thousands) before
22
+ // concluding "no leak". The default is deliberately not tiny.
23
+ // - The verdict (in fts-rss-verdict.mjs) keys on slope DECELERATION, not total
24
+ // delta, with a noise floor that scales with the working-set growth
25
+ // (peak−baseline) so sensitivity tracks fixture/arena size — NOT the pre-DB
26
+ // baseline RSS. A sustained sub-floor positive slope is INCONCLUSIVE (a slow
27
+ // creep RSS can't distinguish from noise), never a clean PLATEAU.
28
+ // - The PLATEAU verdict is only valid for the corpus size it was run at; the
29
+ // output states that size. The production-faithful confirmation is a
30
+ // `--via-pool` run against a real large analyzed repo over a long session.
31
+ //
32
+ // Two modes:
33
+ // (default) NATIVE — reproduces the native sequence doInitLbug()+closeOne()
34
+ // perform (open Database → new Connection → LOAD EXTENSION fts →
35
+ // QUERY_FTS_INDEX → close), against K self-built FTS fixtures, with no
36
+ // gitnexus build required. `--no-await-close` mirrors the pool's
37
+ // fire-and-forget close instead of awaiting (the production close shape).
38
+ // --via-pool <lbugPath> — drives the REAL gitnexus pool from compiled dist
39
+ // (initLbug → executeParameterized → closeLbug) against an existing analyzed
40
+ // repo, exercising the production path + the GITNEXUS_POOL_RSS_TRACE
41
+ // instrumentation. Probes ALL FTS indexes the repo has. Forces an explicit
42
+ // close+reinit each cycle. Run `node scripts/build.js` first so the dist
43
+ // reflects the current pool-adapter (incl. the RSS trace).
44
+ //
45
+ // Run with --expose-gc so RSS excludes V8-heap noise:
46
+ // node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs
47
+ // node --expose-gc gitnexus/scripts/bench/fts-evict-reload-rss.mjs --rows 40000 --cycles 30
48
+ // GITNEXUS_POOL_RSS_TRACE=1 node --expose-gc \
49
+ // gitnexus/scripts/bench/fts-evict-reload-rss.mjs --via-pool /path/to/repo/.gitnexus/lbug
50
+ //
51
+ // Flags by mode: --rows/--repos/--read-write/--no-await-close apply to NATIVE
52
+ // only; --cycles applies to both. VIA-POOL warns when a NATIVE-only flag is set.
53
+ //
54
+ // Memory benches are noisy. Default is 24 cycles; trust the TREND (slope /
55
+ // first-third vs last-third), never a single delta. A flat trend at a LARGE
56
+ // fixture is a real NEGATIVE result (no unbounded leak), not a failed run.
57
+
58
+ import { createRequire } from 'node:module';
59
+ import os from 'node:os';
60
+ import path from 'node:path';
61
+ import fs from 'node:fs';
62
+ // Pure verdict classifier (median, slopeMbPerCycle, classifyVerdict) lives in a
63
+ // side-effect-free sibling module so it is unit-testable without loading the
64
+ // native addon or running this bench. See fts-rss-verdict.mjs.
65
+ import { classifyVerdict, median, slopeMbPerCycle } from './fts-rss-verdict.mjs';
66
+
67
+ const require = createRequire(import.meta.url);
68
+ const lbugModule = require('@ladybugdb/core');
69
+ const lbug = lbugModule.default ?? lbugModule;
70
+
71
+ const LBUG_MAX_DB_SIZE = 16 * 1024 * 1024 * 1024;
72
+
73
+ // ── args ──────────────────────────────────────────────────────────────────
74
+ function argVal(flag, dflt) {
75
+ const i = process.argv.indexOf(flag);
76
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
77
+ }
78
+ const CYCLES = Math.max(6, parseInt(argVal('--cycles', '24'), 10) || 24);
79
+ const REPOS = Math.max(1, parseInt(argVal('--repos', '6'), 10) || 6); // >5 mirrors LRU thrash
80
+ // Fixture size. Default is large enough that a size-proportional leak would be
81
+ // visible across cycles; raise it further before trusting a PLATEAU verdict.
82
+ const ROWS = Math.max(100, parseInt(argVal('--rows', '8000'), 10) || 8000);
83
+ const VIA_POOL = argVal('--via-pool', null);
84
+ const READONLY = !process.argv.includes('--read-write');
85
+ const AWAIT_CLOSE = !process.argv.includes('--no-await-close');
86
+
87
+ if (VIA_POOL) {
88
+ // These flags are consumed only by NATIVE mode; warn rather than ignore
89
+ // silently so a VIA-POOL run is not misread as honoring them.
90
+ const ignored = ['--rows', '--repos', '--read-write', '--no-await-close'].filter((f) =>
91
+ process.argv.includes(f),
92
+ );
93
+ if (ignored.length) {
94
+ console.error(
95
+ `[fts-rss] NOTE: ${ignored.join(', ')} apply to NATIVE mode only; ignored in --via-pool.`,
96
+ );
97
+ }
98
+ }
99
+
100
+ if (typeof global.gc !== 'function') {
101
+ console.error(
102
+ '[fts-rss] WARNING: run with --expose-gc for clean RSS samples ' +
103
+ '(`node --expose-gc <thisfile>`). Continuing without forced GC — results are noisier.',
104
+ );
105
+ }
106
+
107
+ const gc = () => {
108
+ if (typeof global.gc === 'function') {
109
+ global.gc();
110
+ global.gc();
111
+ }
112
+ };
113
+ const rssMb = () => Math.round(process.memoryUsage().rss / (1024 * 1024));
114
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
115
+
116
+ // ── fixture: a minimal FTS-bearing .lbug ────────────────────────────────────
117
+ const WORDS = [
118
+ 'login auth session token user password validate verify credential',
119
+ 'parse tree syntax node grammar lexer token ast traversal visitor',
120
+ 'graph query cypher match relation node edge pattern aggregate index',
121
+ 'memory pool buffer arena allocate reclaim evict cache resident heap',
122
+ 'search rank score bm25 fts index stem porter keyword document corpus',
123
+ 'worker fork process spawn kill reclaim isolate native binding addon',
124
+ ];
125
+
126
+ function buildFixture(dir) {
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ const dbPath = path.join(dir, 'fixture.lbug');
129
+ const db = new lbug.Database(dbPath, 0, false, false, LBUG_MAX_DB_SIZE);
130
+ const conn = new lbug.Connection(db);
131
+ return (async () => {
132
+ await conn.query('LOAD EXTENSION fts');
133
+ await conn.query(
134
+ 'CREATE NODE TABLE Doc(id STRING, name STRING, content STRING, PRIMARY KEY(id))',
135
+ );
136
+ // Batch-insert via UNWIND so large fixtures (`--rows`) build in seconds
137
+ // instead of one round-trip per row. The fixture size drives the per-arena
138
+ // FTS allocation, which is what makes a size-proportional leak observable.
139
+ const rows = [];
140
+ for (let i = 0; i < ROWS; i++) {
141
+ const w = WORDS[i % WORDS.length];
142
+ const name = `sym_${i}`;
143
+ const content = `${w} ${name} block number ${i} ${WORDS[(i + 3) % WORDS.length]}`;
144
+ rows.push({ id: `doc:${i}`, name, content });
145
+ }
146
+ const INSERT_CHUNK = 2000;
147
+ for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
148
+ const chunk = rows.slice(i, i + INSERT_CHUNK);
149
+ const stmt = await conn.prepare(
150
+ 'UNWIND $rows AS r CREATE (:Doc {id: r.id, name: r.name, content: r.content})',
151
+ );
152
+ await conn.execute(stmt, { rows: chunk });
153
+ }
154
+ await conn.query(
155
+ "CALL CREATE_FTS_INDEX('Doc', 'doc_fts', ['name', 'content'], stemmer := 'porter')",
156
+ );
157
+ await conn.close();
158
+ await db.close();
159
+ return dbPath;
160
+ })();
161
+ }
162
+
163
+ const QUERIES = ['login token', 'parse node', 'memory arena', 'search index', 'worker reclaim'];
164
+
165
+ // ── NATIVE mode ─────────────────────────────────────────────────────────────
166
+ async function runNative() {
167
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fts-rss-'));
168
+ console.error(
169
+ `[fts-rss] NATIVE: ${REPOS} fixtures × ${ROWS} rows × ${CYCLES} cycles ` +
170
+ `(readOnly=${READONLY}, awaitClose=${AWAIT_CLOSE})`,
171
+ );
172
+ console.error(`[fts-rss] building ${REPOS} FTS fixture(s) under ${root} …`);
173
+
174
+ const srcDb = await buildFixture(path.join(root, 'src'));
175
+ const repoPaths = [];
176
+ for (let k = 0; k < REPOS; k++) {
177
+ const dst = path.join(root, `repo-${k}`);
178
+ fs.cpSync(path.dirname(srcDb), dst, { recursive: true });
179
+ repoPaths.push(path.join(dst, 'fixture.lbug'));
180
+ }
181
+
182
+ // Mirror the pool's evict→reload: each visit opens a FRESH Database, makes a
183
+ // Connection, LOADs fts, runs an FTS query, then closes — no caching, so every
184
+ // visit is a reload. K>5 amplifies the LRU-thrash signal the pool would see.
185
+ const series = [];
186
+ gc();
187
+ await sleep(50);
188
+ const baseline = rssMb();
189
+ console.error(`[fts-rss] baseline RSS=${baseline}MB`);
190
+
191
+ for (let cycle = 0; cycle < CYCLES; cycle++) {
192
+ for (let k = 0; k < REPOS; k++) {
193
+ const db = new lbug.Database(repoPaths[k], 0, false, READONLY, LBUG_MAX_DB_SIZE);
194
+ const conn = new lbug.Connection(db);
195
+ try {
196
+ await conn.query('LOAD EXTENSION fts'); // the per-reload re-LOAD under test
197
+ const q = QUERIES[(cycle + k) % QUERIES.length];
198
+ const res = await conn.query(
199
+ `CALL QUERY_FTS_INDEX('Doc', 'doc_fts', '${q}') RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
200
+ );
201
+ // Drain so the query actually materializes results.
202
+ if (res && typeof res.getAll === 'function') await res.getAll();
203
+ } catch (e) {
204
+ console.error(`[fts-rss] query error (cycle ${cycle}, repo ${k}): ${e?.message || e}`);
205
+ } finally {
206
+ // AWAIT_CLOSE (default) is the best case for reclamation. --no-await-close
207
+ // mirrors the pool's fire-and-forget close (closeOne: db.close().catch())
208
+ // so a leak that only manifests without awaiting is not hidden.
209
+ if (AWAIT_CLOSE) {
210
+ try {
211
+ await conn.close();
212
+ await db.close();
213
+ } catch {
214
+ /* ignore */
215
+ }
216
+ } else {
217
+ conn.close().catch(() => {});
218
+ db.close().catch(() => {});
219
+ }
220
+ }
221
+ }
222
+ gc();
223
+ // Longer settle when not awaiting close, so fire-and-forget native teardown
224
+ // has a chance to complete before the RSS sample (avoids a false PLATEAU).
225
+ await sleep(AWAIT_CLOSE ? 20 : 200);
226
+ const rss = rssMb();
227
+ series.push(rss);
228
+ console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
229
+ }
230
+
231
+ fs.rmSync(root, { recursive: true, force: true });
232
+ return { baseline, series, corpus: `${REPOS}×${ROWS} rows, native, awaitClose=${AWAIT_CLOSE}` };
233
+ }
234
+
235
+ // ── VIA-POOL mode (real gitnexus pool from compiled dist) ───────────────────
236
+ async function runViaPool(lbugPath) {
237
+ if (!fs.existsSync(lbugPath)) {
238
+ console.error(`[fts-rss] --via-pool path not found: ${lbugPath}`);
239
+ process.exit(2);
240
+ }
241
+ // Compiled dist is required (the pool pulls the native addon + many modules).
242
+ const distUrl = new URL('../../dist/core/lbug/pool-adapter.js', import.meta.url);
243
+ let pool;
244
+ try {
245
+ pool = await import(distUrl.href);
246
+ } catch (e) {
247
+ console.error(
248
+ `[fts-rss] could not import compiled pool-adapter (${e?.message}). ` +
249
+ `Run \`node scripts/build.js\` first, or use NATIVE mode.`,
250
+ );
251
+ process.exit(2);
252
+ }
253
+ const { initLbug, executeParameterized, closeLbug } = pool;
254
+ console.error(
255
+ `[fts-rss] VIA-POOL on ${lbugPath} × ${CYCLES} cycles ` +
256
+ `(explicit closeLbug+initLbug per cycle = forced evict→reload)`,
257
+ );
258
+
259
+ // Probe ALL FTS indexes the analyzed graph carries (mirrors fts-schema.ts
260
+ // FTS_INDEXES) so the per-cycle FTS arena load matches production, not a
261
+ // 2-of-5 subset that would understate it.
262
+ const FTS_INDEXES = [
263
+ { table: 'File', indexName: 'file_fts' },
264
+ { table: 'Function', indexName: 'function_fts' },
265
+ { table: 'Class', indexName: 'class_fts' },
266
+ { table: 'Method', indexName: 'method_fts' },
267
+ { table: 'Interface', indexName: 'interface_fts' },
268
+ ];
269
+
270
+ const series = [];
271
+ gc();
272
+ const baseline = rssMb();
273
+ console.error(`[fts-rss] baseline RSS=${baseline}MB`);
274
+
275
+ for (let cycle = 0; cycle < CYCLES; cycle++) {
276
+ try {
277
+ await initLbug(lbugPath, lbugPath);
278
+ const q = QUERIES[cycle % QUERIES.length];
279
+ for (const { table, indexName } of FTS_INDEXES) {
280
+ await executeParameterized(
281
+ lbugPath,
282
+ `CALL QUERY_FTS_INDEX('${table}', '${indexName}', $q) RETURN node.id AS id, score ORDER BY score DESC LIMIT 20`,
283
+ { q },
284
+ ).catch(() => []); // index may not exist for this graph — that's fine
285
+ }
286
+ await closeLbug(lbugPath); // force eviction → next cycle reopens + re-LOADs fts
287
+ } catch (e) {
288
+ console.error(`[fts-rss] pool cycle ${cycle} error: ${e?.message || e}`);
289
+ }
290
+ gc();
291
+ // closeLbug fires a fire-and-forget native close (pool closeOne:
292
+ // db.close().catch()), so settle longer than NATIVE's awaited close to let
293
+ // native teardown finish before sampling — else a real leak reads PLATEAU.
294
+ await sleep(200);
295
+ const rss = rssMb();
296
+ series.push(rss);
297
+ console.error(`[fts-rss] cycle ${String(cycle + 1).padStart(3)}/${CYCLES} rssMB=${rss}`);
298
+ }
299
+ await closeLbug().catch(() => {});
300
+ return { baseline, series, corpus: `via-pool ${path.basename(path.dirname(lbugPath))}` };
301
+ }
302
+
303
+ // ── verdict ─────────────────────────────────────────────────────────────────
304
+ function verdict({ baseline, series, corpus }) {
305
+ const third = Math.max(1, Math.floor(series.length / 3));
306
+ const firstMed = median(series.slice(0, third));
307
+ const lastMed = median(series.slice(-third));
308
+ const delta = lastMed - firstMed;
309
+ const slope = slopeMbPerCycle(series);
310
+
311
+ // All label logic lives in the pure, unit-tested classifier (fts-rss-verdict.mjs):
312
+ // epsilon-first flat→PLATEAU, decelerated→PLATEAU, sustained-sub-floor→INCONCLUSIVE,
313
+ // ≥floor sustained→CLIMB, step→INCONCLUSIVE; floor scales with the working-set
314
+ // growth (peak−baseline), not the pre-DB baseline RSS.
315
+ const {
316
+ verdict: label,
317
+ firstHalfSlope,
318
+ secondHalfSlope,
319
+ decelRatio,
320
+ floor,
321
+ stepDiscontinuity,
322
+ maxJump,
323
+ peak,
324
+ } = classifyVerdict(series, baseline);
325
+
326
+ console.log('\n==================== FTS evict→reload RSS verdict ====================');
327
+ console.log(`corpus: ${corpus}`);
328
+ console.log(`samples (MB): ${series.join(' ')}`);
329
+ console.log(
330
+ `baseline=${baseline} firstThirdMed=${firstMed} lastThirdMed=${lastMed} delta=${delta}MB ` +
331
+ `peak=${peak} overallSlope=${slope.toFixed(2)} firstHalfSlope=${firstHalfSlope.toFixed(2)} ` +
332
+ `secondHalfSlope=${secondHalfSlope.toFixed(2)}MB/cycle floor=${floor.toFixed(2)} decelRatio=${decelRatio.toFixed(2)} ` +
333
+ `maxJump=${maxJump}MB step=${stepDiscontinuity} cycles=${series.length}`,
334
+ );
335
+ if (label === 'CLIMB') {
336
+ console.log(
337
+ 'VERDICT: CLIMB — the per-cycle increment is SUSTAINED (second-half slope ≈ first-half),\n' +
338
+ ' i.e. RSS rises ~linearly with no decay. The native FTS arena is NOT reclaimed\n' +
339
+ ' by db.close(); the leak is real over a long-lived session.\n' +
340
+ ' → plan U4 (worker/process isolation of the FTS read path) is JUSTIFIED.',
341
+ );
342
+ } else if (label === 'PLATEAU') {
343
+ console.log(
344
+ `VERDICT: PLATEAU at this corpus (${corpus}) — the per-cycle increment DECAYS to flat\n` +
345
+ ' (second-half slope below the noise floor). db.close() reclaims the FTS arena;\n' +
346
+ ' footprint is bounded (and the pool further caps it at MAX_POOL_SIZE). No\n' +
347
+ ' unbounded leak. Caveat: synthetic fixture — confirm with a --via-pool run\n' +
348
+ ' against a real large analyzed repo before fully closing plan U4.',
349
+ );
350
+ } else {
351
+ console.log(
352
+ `VERDICT: INCONCLUSIVE at this corpus (${corpus}) — the run is noisy (step discontinuity)\n` +
353
+ ' or still decelerating without reaching flat, so neither a clean PLATEAU nor a\n' +
354
+ ' sustained linear CLIMB can be asserted. NATIVE synthetic runs do not resolve\n' +
355
+ ' this reliably at scale. The definitive test is a --via-pool run against a real\n' +
356
+ ' large analyzed repo over many cycles (with GITNEXUS_POOL_RSS_TRACE=1). Plan U4\n' +
357
+ ' stays GATED — neither closed nor built on this evidence.',
358
+ );
359
+ }
360
+ console.log(
361
+ `MACHINE: ${JSON.stringify({ mode: VIA_POOL ? 'via-pool' : 'native', corpus, baseline, firstMed, lastMed, delta, overallSlope: Number(slope.toFixed(3)), firstHalfSlope: Number(firstHalfSlope.toFixed(3)), secondHalfSlope: Number(secondHalfSlope.toFixed(3)), floor: Number(floor.toFixed(3)), decelRatio: Number(decelRatio.toFixed(3)), maxJump, stepDiscontinuity, peak, cycles: series.length, verdict: label })}`,
362
+ );
363
+ console.log('=====================================================================\n');
364
+ }
365
+
366
+ // ── main ────────────────────────────────────────────────────────────────────
367
+ (async () => {
368
+ const result = VIA_POOL ? await runViaPool(VIA_POOL) : await runNative();
369
+ verdict(result);
370
+ process.exit(0);
371
+ })().catch((e) => {
372
+ console.error('[fts-rss] fatal:', e?.stack || e);
373
+ process.exit(1);
374
+ });
@@ -0,0 +1,105 @@
1
+ // Pure, side-effect-free verdict classifier for the FTS evict→reload RSS bench
2
+ // (fts-evict-reload-rss.mjs). Extracted so it can be unit-tested WITHOUT importing
3
+ // the native LadybugDB addon or running the bench — this module has zero imports
4
+ // and zero module-scope side effects. Do not add imports or top-level statements.
5
+ //
6
+ // The discriminant between a real leak and allocator warmup is SLOPE DECELERATION,
7
+ // not total delta. A true per-reload leak (stranded FTS arena) rises ~linearly:
8
+ // the second-half slope stays ≈ the first-half slope. Allocator working-set warmup
9
+ // rises then flattens: the second-half slope decays to a fraction of the first.
10
+ //
11
+ // Thresholds:
12
+ // EPSILON (~0.1 MB/cycle) — below this the tail is effectively flat (no leak).
13
+ // SUSTAIN_FLOOR (0.5 MB/cycle) — the base noise floor.
14
+ // The floor SCALES with the working-set growth (peak − baseline), NOT the pre-DB
15
+ // `baseline` RSS: baseline is interpreter/addon overhead (and is LARGER in
16
+ // --via-pool mode), so a baseline-keyed floor would inflate and HIDE leaks. A
17
+ // bigger fixture has a bigger arena and bigger per-cycle noise, so the floor
18
+ // rises with the working set: floor = SUSTAIN_FLOOR · max(1, (peak−baseline)/REF).
19
+
20
+ export const EPSILON_MB_PER_CYCLE = 0.1;
21
+ export const SUSTAIN_FLOOR = 0.5;
22
+ // Reference working-set (MB) at which the floor equals SUSTAIN_FLOOR; the floor
23
+ // scales up linearly for larger arenas. ~200 MB ≈ a small FTS fixture's footprint.
24
+ export const FLOOR_REF_WORKINGSET_MB = 200;
25
+
26
+ export function median(xs) {
27
+ const s = [...xs].sort((a, b) => a - b);
28
+ const m = Math.floor(s.length / 2);
29
+ return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
30
+ }
31
+
32
+ export function slopeMbPerCycle(series) {
33
+ // Least-squares slope of rss vs cycle index.
34
+ const n = series.length;
35
+ if (n < 2) return 0;
36
+ const xs = series.map((_, i) => i);
37
+ const xMean = xs.reduce((a, b) => a + b, 0) / n;
38
+ const yMean = series.reduce((a, b) => a + b, 0) / n;
39
+ let num = 0;
40
+ let den = 0;
41
+ for (let i = 0; i < n; i++) {
42
+ num += (xs[i] - xMean) * (series[i] - yMean);
43
+ den += (xs[i] - xMean) ** 2;
44
+ }
45
+ return den === 0 ? 0 : num / den;
46
+ }
47
+
48
+ /**
49
+ * Classify an RSS-per-cycle series into PLATEAU / CLIMB / INCONCLUSIVE.
50
+ * Pure: no I/O, no globals. `baseline` is the pre-DB RSS; `peak` defaults to the
51
+ * series max. Returns the label plus the diagnostics the bench prints.
52
+ */
53
+ export function classifyVerdict(series, baseline, peak = Math.max(...series)) {
54
+ const cycles = series.length;
55
+ const half = Math.max(1, Math.floor(cycles / 2));
56
+ const firstHalfSlope = slopeMbPerCycle(series.slice(0, half));
57
+ const secondHalfSlope = slopeMbPerCycle(series.slice(-half));
58
+ const decelRatio = secondHalfSlope / Math.max(firstHalfSlope, 1e-9);
59
+
60
+ // Step discontinuity: a single cycle-to-cycle jump far larger than the typical
61
+ // per-cycle delta — a one-time allocator/arena reservation (then flat), not a
62
+ // per-reload leak, but a noisy run we won't claim a clean result on.
63
+ const deltas = series.slice(1).map((v, i) => v - series[i]);
64
+ const absDeltas = deltas.map(Math.abs).sort((a, b) => a - b);
65
+ const medAbsDelta = absDeltas.length ? absDeltas[Math.floor(absDeltas.length / 2)] : 0;
66
+ const maxJump = deltas.length ? Math.max(...deltas) : 0;
67
+ const stepDiscontinuity = maxJump > Math.max(30, 5 * Math.max(medAbsDelta, 1));
68
+
69
+ // Working-set-scaled floor (see header). Guard against a negative working set.
70
+ const workingSet = Math.max(0, peak - baseline);
71
+ const floor = SUSTAIN_FLOOR * Math.max(1, workingSet / FLOOR_REF_WORKINGSET_MB);
72
+
73
+ const SUSTAINED = 0.6; // decelRatio at/above which the tail is "not decaying"
74
+ let verdict;
75
+ if (stepDiscontinuity) {
76
+ verdict = 'INCONCLUSIVE';
77
+ } else if (secondHalfSlope < EPSILON_MB_PER_CYCLE) {
78
+ // Effectively flat — no leak, regardless of decelRatio (a flat-from-start run
79
+ // has decelRatio ≈ 1 but is still PLATEAU). This gate is what keeps a true
80
+ // negative from being over-corrected into INCONCLUSIVE.
81
+ verdict = 'PLATEAU';
82
+ } else if (secondHalfSlope >= floor) {
83
+ // Tail is still substantial: sustained → real leak; decelerating → unresolved.
84
+ verdict = decelRatio >= SUSTAINED ? 'CLIMB' : 'INCONCLUSIVE';
85
+ } else if (decelRatio < SUSTAINED) {
86
+ // Below the floor AND decelerating — warmup converged toward flat → PLATEAU.
87
+ verdict = 'PLATEAU';
88
+ } else {
89
+ // Below the floor but SUSTAINED — a slow steady creep RSS can't distinguish
90
+ // from noise at this scale. The honest label is "not resolved", NEVER a clean
91
+ // PLATEAU ("no leak"). This is the headline tri-review fix.
92
+ verdict = 'INCONCLUSIVE';
93
+ }
94
+
95
+ return {
96
+ verdict,
97
+ firstHalfSlope,
98
+ secondHalfSlope,
99
+ decelRatio,
100
+ floor,
101
+ stepDiscontinuity,
102
+ maxJump,
103
+ peak,
104
+ };
105
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Probe tree-sitter-kotlin native-binding availability at install time.
4
+ *
5
+ * Unlike Dart/Proto/Swift (vendored under vendor/ and materialized into
6
+ * node_modules/ at postinstall), tree-sitter-kotlin is a third-party npm
7
+ * `optionalDependency`. It ships SOURCE ONLY — no upstream `prebuilds/` dir —
8
+ * and its own `install` script runs `node-gyp-build`, which compiles the
9
+ * native binding from source via node-gyp. On a host without a C/C++ toolchain
10
+ * that build soft-fails: npm skips the optional dependency and the `gitnexus`
11
+ * install still succeeds. This probe surfaces a single, friendly install-time
12
+ * warning when the Kotlin binding is unavailable — whether npm pruned the
13
+ * optional dependency after a toolchain-less build failure (its dir is gone,
14
+ * which is the common case) or the dir survives but the binding won't load —
15
+ * instead of leaving a raw node-gyp error or a first-use runtime failure as the
16
+ * only signal. A deliberate opt-out (`--omit=optional`) stays silent. The probe
17
+ * does not copy, register, or mutate anything; the runtime require() path in
18
+ * parser-loader does the actual load. This probe MUST NEVER throw or exit
19
+ * non-zero — it must never break `gitnexus` install.
20
+ */
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
25
+ console.warn(
26
+ '[tree-sitter-kotlin] Skipping native-binding probe (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1).',
27
+ );
28
+ process.exit(0);
29
+ }
30
+
31
+ const kotlinDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-kotlin');
32
+
33
+ // `--omit=optional` / `--no-optional` / `.npmrc omit=optional` surface to
34
+ // lifecycle scripts as `npm_config_omit` containing `optional` (a comma- or
35
+ // space-separated list, e.g. `dev,optional`). That is a deliberate opt-out, so
36
+ // an absent package for that reason should stay silent. Any OTHER absence means
37
+ // npm attempted the optional dependency's native build and pruned the package
38
+ // after it soft-failed (the toolchain-less case) — exactly when the guidance
39
+ // below is worth surfacing.
40
+ const omitsOptional = /(^|[,\s])optional([,\s]|$)/.test(process.env.npm_config_omit || '');
41
+
42
+ function warnKotlinUnavailable(err) {
43
+ if (err) {
44
+ console.warn('[tree-sitter-kotlin] Native-binding probe failed:', err.message);
45
+ }
46
+ console.warn(
47
+ '[tree-sitter-kotlin] Kotlin (.kt/.kts) parsing will be unavailable. Non-Kotlin functionality is unaffected.',
48
+ );
49
+ console.warn(
50
+ '[tree-sitter-kotlin] This is expected on hosts without a C/C++ toolchain: tree-sitter-kotlin ships source only (no upstream prebuilt binaries) and compiles via node-gyp at install. Set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 to skip this probe.',
51
+ );
52
+ }
53
+
54
+ try {
55
+ if (!fs.existsSync(path.join(kotlinDir, 'bindings', 'node', 'index.js'))) {
56
+ // The package never materialized. If the user deliberately omitted optional
57
+ // dependencies, stay silent — they opted out. Otherwise npm pruned the
58
+ // package after its native build soft-failed (no toolchain), and this is the
59
+ // dominant real-world failure case: surface the guidance the raw node-gyp
60
+ // error would otherwise be the only signal of.
61
+ if (!omitsOptional) {
62
+ warnKotlinUnavailable();
63
+ }
64
+ process.exit(0);
65
+ }
66
+
67
+ const nodeGypBuild = require('node-gyp-build');
68
+ nodeGypBuild(kotlinDir);
69
+ } catch (err) {
70
+ // The package is present but its native binding can't be loaded (e.g. the dir
71
+ // survived with --ignore-scripts, or a partial/ABI-mismatched build).
72
+ warnKotlinUnavailable(err);
73
+ process.exit(0);
74
+ }
@@ -14,7 +14,7 @@ function parseLbugMaxDbSize(raw) {
14
14
  return Math.floor(parsed);
15
15
  }
16
16
 
17
- async function installDuckDbExtension(extensionName) {
17
+ async function installDuckDbExtension(extensionName, verifyOnly = false) {
18
18
  if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) {
19
19
  throw new Error(`Invalid DuckDB extension name: ${extensionName ?? '<missing>'}`);
20
20
  }
@@ -22,9 +22,11 @@ async function installDuckDbExtension(extensionName) {
22
22
  const require = createRequire(import.meta.url);
23
23
  const lbugModule = require('@ladybugdb/core');
24
24
  const lbug = lbugModule.default ?? lbugModule;
25
- const lbugMaxDbSize = parseLbugMaxDbSize(
26
- process.argv[3] ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE,
27
- );
25
+ // argv[3] is the optional positional size; ignore it when it is actually a
26
+ // flag token (e.g. `--verify-only`) and fall back to the env default.
27
+ const sizeArg =
28
+ process.argv[3] && !process.argv[3].startsWith('--') ? process.argv[3] : undefined;
29
+ const lbugMaxDbSize = parseLbugMaxDbSize(sizeArg ?? process.env.GITNEXUS_LBUG_MAX_DB_SIZE);
28
30
 
29
31
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-'));
30
32
  const dbPath = path.join(tmpDir, 'install.lbug');
@@ -34,7 +36,18 @@ async function installDuckDbExtension(extensionName) {
34
36
  try {
35
37
  db = new lbug.Database(dbPath, 0, false, false, lbugMaxDbSize);
36
38
  conn = new lbug.Connection(db);
37
- await conn.query(`INSTALL ${extensionName}`);
39
+ if (verifyOnly) {
40
+ // Prove a previously-baked extension is resolvable by a FRESH process
41
+ // under the current HOME (the runtime `LOAD EXTENSION` path) — no INSTALL,
42
+ // no network. Used as a Docker build-time gate so a HOME/extension-dir
43
+ // mismatch fails the build instead of silently degrading search at runtime.
44
+ await conn.query(`LOAD EXTENSION ${extensionName}`);
45
+ console.log(
46
+ `[install-ext] LOAD-only verify OK for '${extensionName}' (HOME=${process.env.HOME})`,
47
+ );
48
+ } else {
49
+ await conn.query(`INSTALL ${extensionName}`);
50
+ }
38
51
  } finally {
39
52
  if (conn) await conn.close().catch(() => {});
40
53
  if (db) await db.close().catch(() => {});
@@ -42,7 +55,10 @@ async function installDuckDbExtension(extensionName) {
42
55
  }
43
56
  }
44
57
 
45
- installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => {
58
+ installDuckDbExtension(
59
+ process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME,
60
+ process.argv.includes('--verify-only'),
61
+ ).catch((err) => {
46
62
  console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
47
63
  process.exitCode = 1;
48
64
  });