Merge branch 'main' into feat/multi-cli-devcontainer

This commit is contained in:
Gergő Magyar 2026-05-29 20:12:59 +01:00 • committed by GitHub
commit c8fc0ddd8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 470 additions and 41 deletions

View file

@ -1096,6 +1096,17 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
);
console.log(` ${repoPath}`);
// Persistent (non-scrolling) warning when FTS indexing was skipped — the
// progress-bar log() that fired mid-run has already scrolled away, so the
// degraded-search state must also appear in the final summary (#1161).
if (result.ftsSkipped) {
console.log(
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
);
}
try {
await fs.access(getGlobalRegistryPath());
} catch {

View file

@ -2,6 +2,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
import { checkLbugNative } from '../core/lbug/native-check.js';
import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js';
import { t } from './i18n/index.js';
function isCombiningMark(codePoint: number): boolean {
@ -74,6 +75,17 @@ export const doctorCommand = async () => {
console.log(` ${label('doctor.labels.fullTextSearch', 18)}${capabilities.fts}`);
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
// Surface the optional-extension install policy so offline users can see
// whether analyze/query will reach the network (extension.ladybugdb.com).
// Literal label (like the 'native' line) to avoid adding i18n keys.
const installPolicy = getExtensionInstallPolicy();
const policyHint =
installPolicy === 'load-only'
? ' (offline; load only, no network install)'
: installPolicy === 'never'
? ' (optional extensions disabled)'
: ' (installs missing extensions over network)';
console.log(` ${padDisplayEnd('Ext install:', 18)}${installPolicy}${policyHint}`);
console.log(
` ${label('doctor.labels.exactScanLimit', 18)}${t('doctor.chunks', { count: capabilities.exactScanLimit })}`,
);

View file

@ -43,20 +43,38 @@ import {
STALE_HASH_SENTINEL,
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
import { getExactScanLimit } from '../platform/capabilities.js';
import { logger } from '../logger.js';
const isDev = process.env.NODE_ENV === 'development';
const vectorUnavailableMessage =
'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.';
'VECTOR extension unavailable; semantic embeddings fall back to exact scan. ' +
'To enable vector search, install it once with network access ' +
'(GITNEXUS_LBUG_EXTENSION_INSTALL=auto), or pre-install it for offline use. ' +
'Set GITNEXUS_LBUG_EXTENSION_INSTALL=never to skip installs and silence this.';
/**
* Resolve the extension-install policy for the embedding WRITE path (analyze).
*
* Generating embeddings is an explicit opt-in to a feature that requires the
* VECTOR extension, so when the operator has NOT pinned a policy we default to
* `auto` (one bounded, out-of-process INSTALL) — matching the documented
* "auto = default for analyze" intent in extension-loader.ts. An explicit
* GITNEXUS_LBUG_EXTENSION_INSTALL=load-only|never|auto always wins, so an
* offline or locked-down operator is never silently forced onto the network
* (the #1153 regression caused by hard-coding `auto` here). Read on every call
* (not memoized) so test env stubbing works.
*/
export const resolveEmbeddingInstallPolicy = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'auto';
};
const ensureVectorExtensionAvailable = async (): Promise<boolean> => {
const vectorReady = await loadVectorExtension();
if (!vectorReady) {
return false;
}
return true;
return loadVectorExtension(undefined, { policy: resolveEmbeddingInstallPolicy() });
};
/**
* Bump this when the embedding text template changes in a way that should
@ -257,7 +275,7 @@ export const runEmbeddingPipeline = async (
try {
const vectorAvailable = await ensureVectorExtensionAvailable();
if (!vectorAvailable && isDev) {
if (!vectorAvailable) {
logger.warn(vectorUnavailableMessage);
}
@ -584,7 +602,11 @@ export const semanticSearch = async (
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
if (await loadVectorExtension()) {
// Query/read path: NEVER spawn a network INSTALL on a user query. If the
// VECTOR extension was not pre-installed, fall back to exact scan rather than
// blocking the query on a download (offline-first; see extension-loader.ts
// "load-only" — used by all serve/MCP query paths).
if (await loadVectorExtension(undefined, { policy: 'load-only' })) {
try {
bestChunks = await collectBestChunks(k, async (fetchLimit) => {
const vectorQuery = `

View file

@ -51,6 +51,28 @@ const alreadyAvailable = (message: string): boolean =>
message.includes('already exists');
const resolvePolicyFromEnv = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'load-only';
};
export const getExtensionInstallPolicy = (): ExtensionInstallPolicy => resolvePolicyFromEnv();
/**
* Install policy for the **analyze (write) path**.
*
* The global default (`resolvePolicyFromEnv`) is `load-only` so serve/query
* read paths never require outbound network access (PR #1161, offline-first).
* The analyze path is different: it owns building the search indexes, so it
* defaults to `auto` — LOAD the extension if present, otherwise attempt one
* bounded out-of-process INSTALL. This keeps FTS symmetric with the
* VECTOR/embeddings path (which already defaults to `auto`) and matches the
* #726 contract. An explicit `GITNEXUS_LBUG_EXTENSION_INSTALL` value still
* wins, so operators can force `load-only`/`never` for fully offline analyze;
* `auto` LOADs-first, so offline machines still degrade gracefully when the
* INSTALL cannot reach the network.
*/
export const resolveAnalyzeInstallPolicy = (): ExtensionInstallPolicy => {
const raw = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
if (raw === 'load-only' || raw === 'never' || raw === 'auto') return raw;
return 'auto';
@ -148,7 +170,7 @@ export const installDuckDbExtensionOutOfProcess = async (
* subsequent analyze or query calls.
*
* Policy precedence (most specific wins):
* per-call `opts.policy` → constructor `options.policy` → env → `auto`
* per-call `opts.policy` → constructor `options.policy` → env → `load-only`
*/
export class ExtensionManager {
private readonly capabilities = new Map<string, ExtensionCapability>();

View file

@ -24,8 +24,10 @@ import {
deleteNodesForFile,
deleteAllCommunitiesAndProcesses,
queryImporters,
loadFTSExtension,
} from './lbug/lbug-adapter.js';
import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
import {
startWalCheckpointDriver,
type WalCheckpointDriver,
@ -144,8 +146,26 @@ export interface AnalyzeResult {
pipelineResult?: any;
/** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */
ftsRepairedOnly?: boolean;
/**
* True when the FTS extension was unavailable so search-index creation was
* skipped (offline-first degradation). The graph is fully queryable; only
* full-text/BM25 search is disabled. Lets callers (CLI summary, server) and
* the persisted meta surface the degraded state instead of reporting healthy.
*/
ftsSkipped?: boolean;
}
/**
* Logged when the optional FTS extension cannot be loaded or installed during
* a full analyze. Kept as a named constant so the env-var/command guidance
* stays in one place (mirrors the VECTOR message in embedding-pipeline.ts).
*/
const FTS_UNAVAILABLE_MESSAGE =
'FTS extension unavailable; skipping search-index creation. ' +
'Full-text/BM25 search will be disabled until the LadybugDB FTS extension is ' +
'installed once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) or ' +
'pre-installed for offline use. Run `gitnexus doctor` for details.';
// Re-export the pure flag-derivation helper so external callers (and tests)
// keep importing from this module's stable surface.
export { deriveEmbeddingMode, DEFAULT_EMBEDDING_NODE_LIMIT } from './embedding-mode.js';
@ -684,23 +704,41 @@ export async function runFullAnalysis(
}
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
// The analyze (write) path owns building the search indexes, so it uses
// the `auto` install policy (LOAD-first, then one bounded INSTALL) —
// symmetric with the VECTOR/embeddings path below and consistent with the
// #726 contract. The global `load-only` default (PR #1161) governs the
// serve/query read paths, not this one. When the extension still cannot be
// loaded (genuinely offline + not pre-installed, or policy forced to
// load-only/never), degrade gracefully — exactly like the VECTOR path — so
// analyze still produces a fully queryable graph; only full-text/BM25
// search falls back. `--repair-fts` (whose sole job is FTS) still fails
// loudly on its own path above.
progress('fts', 85, 'Creating search indexes...');
await createSearchFTSIndexes({
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
onIndexReady: options.verbose
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
const ftsAvailable = await loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
if (missingIndexNames.length > 0) {
throw new Error(
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
);
if (ftsAvailable) {
await createSearchFTSIndexes({
onIndexStart: options.verbose
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
: undefined,
onIndexReady: options.verbose
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
: undefined,
});
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
if (missingIndexNames.length > 0) {
throw new Error(
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
);
}
progress('fts', 90, 'Search indexes ready');
} else {
log(FTS_UNAVAILABLE_MESSAGE);
progress('fts', 90, 'Search indexes skipped (FTS unavailable)');
}
progress('fts', 90, 'Search indexes ready');
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
// Runs on BOTH the full-rebuild path and the incremental path:
@ -889,7 +927,14 @@ export async function runFullAnalysis(
},
capabilities: {
graph: { provider: 'ladybugdb', status: runtimeCapabilities.graph },
fts: { provider: 'ladybugdb-fts', status: runtimeCapabilities.fts },
// Reflect what this analyze run actually produced: when the FTS
// extension was unavailable the indexes were skipped, so record
// 'unavailable' rather than the static runtime default. Keeps
// meta.json / `gitnexus doctor` honest about degraded search.
fts: {
provider: 'ladybugdb-fts',
status: ftsAvailable ? runtimeCapabilities.fts : 'unavailable',
},
vectorSearch: {
provider: effectiveSemanticMode === 'vector-index' ? 'ladybugdb-vector' : 'exact-scan',
status: embeddingCount > 0 ? effectiveSemanticMode : 'unavailable',
@ -989,6 +1034,7 @@ export async function runFullAnalysis(
repoPath,
stats: meta.stats,
pipelineResult,
ftsSkipped: !ftsAvailable,
};
} catch (err) {
// Ensure LadybugDB is closed even on error. Stop the driver first

View file

@ -284,6 +284,37 @@ Follow these steps:
/**
* Start the MCP server on stdio transport (for CLI use).
*/
/** Force-exit fallback budget if graceful shutdown cleanup hangs. */
const SHUTDOWN_FORCE_EXIT_MS = 5_000;
/** Conventional 128 + signal-number exit codes for graceful termination. */
export const SHUTDOWN_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 } as const;
type SignalRegistrar = (
event: 'SIGINT' | 'SIGTERM',
listener: (...args: unknown[]) => void,
) => void;
/**
* Wire SIGINT/SIGTERM to a graceful shutdown using NUMERIC exit codes.
*
* Node invokes signal listeners with the signal NAME string as the first
* argument, so registering an `(exitCode = 0) => process.exit(exitCode)`
* shutdown directly passes `'SIGTERM'` into `process.exit()` and crashes with
* `ERR_INVALID_ARG_TYPE` (#1132). These wrappers discard the signal argument
* and pass the conventional 128+signal code instead. `on` is injectable so the
* mapping can be unit-tested without touching the real process.
*/
export function installSignalShutdown(
shutdown: (exitCode?: number) => unknown,
on: SignalRegistrar = (event, listener) => {
process.on(event, listener);
},
): void {
on('SIGINT', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGINT));
on('SIGTERM', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGTERM));
}
export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = createMCPServer(backend);
@ -321,6 +352,11 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
const shutdown = async (exitCode = 0) => {
if (shuttingDown) return;
shuttingDown = true;
// Safety net: if backend.disconnect()/server.close() hangs, still exit so a
// SIGINT/SIGTERM reliably terminates the process. Unref'd so the timer alone
// never keeps the event loop alive.
const forceExit = setTimeout(() => process.exit(exitCode), SHUTDOWN_FORCE_EXIT_MS);
forceExit.unref();
try {
await backend.disconnect();
} catch {}
@ -329,12 +365,16 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
} catch {}
const { flushLoggerSync } = await import('../core/logger.js');
flushLoggerSync();
clearTimeout(forceExit);
process.exit(exitCode);
};
// Handle graceful shutdown
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Handle graceful shutdown. Node invokes signal listeners with the signal
// NAME (e.g. 'SIGTERM') as the first argument; registering `shutdown`
// directly passed that string to process.exit() and crashed with
// ERR_INVALID_ARG_TYPE (#1132). Map each signal to its conventional
// 128+signal exit code instead.
installSignalShutdown(shutdown);
// Log crashes to stderr so they aren't silently lost.
// uncaughtException is fatal — shut down.
@ -342,14 +382,16 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
// killing the server for one missed catch would be worse than logging it.
process.on('uncaughtException', (err) => {
process.stderr.write(`GitNexus MCP uncaughtException: ${err?.stack || err}\n`);
shutdown(1);
void shutdown(1);
});
process.on('unhandledRejection', (reason: any) => {
process.stderr.write(`GitNexus MCP unhandledRejection: ${reason?.stack || reason}\n`);
});
// Handle stdio errors — stdin close means the parent process is gone
process.stdin.on('end', shutdown);
process.stdin.on('error', () => shutdown());
process.stdout.on('error', () => shutdown());
// Handle stdio errors — stdin close means the parent process is gone.
// Wrap so the event payload (e.g. an Error for 'error') can never reach
// process.exit() as a non-numeric exit code, and void the returned promise.
process.stdin.on('end', () => void shutdown(0));
process.stdin.on('error', () => void shutdown(0));
process.stdout.on('error', () => void shutdown(0));
}

View file

@ -9,7 +9,8 @@
* Seed data is NOT included — each test provides its own via options.seed.
*/
import path from 'path';
import { describe, beforeAll, afterAll } from 'vitest';
import { describe, beforeAll, beforeEach, afterAll } from 'vitest';
import { resolveAnalyzeInstallPolicy } from '../../src/core/lbug/extension-loader.js';
import { createTempDir, type TestDBHandle } from './test-db.js';
import { NODE_TABLES, EMBEDDING_TABLE_NAME } from '../../src/core/lbug/schema.js';
@ -73,6 +74,15 @@ export function withTestLbugDB(
// init on Windows CI regularly exceeds 30s due to native resource setup.
const timeout = options?.timeout ?? 120_000;
// Suites that seed FTS indexes need the optional FTS extension. It is not
// guaranteed on every machine (e.g. the macOS platform-sensitive CI runner,
// where it is neither pre-installed nor installable). Track availability so
// setup can skip FTS seeding instead of throwing, and so every test in the
// suite is skipped rather than failing against a missing index. (PR #1161.)
const ftsRequired = !!options?.ftsIndexes?.length;
let ftsAvailable = true;
let ftsSkipWarned = false;
const setup = async () => {
const tmpHandle = await createTempDir('gitnexus-lbug-');
const dbPath = path.join(tmpHandle.dbPath, 'lbug');
@ -84,6 +94,16 @@ export function withTestLbugDB(
// already open for this dbPath (no new native objects created).
await adapter.initLbug(dbPath);
// 1b. Probe the FTS extension for suites that need it, mirroring the
// analyze write path (`auto`: LOAD-first, then one bounded INSTALL).
// When it still cannot load, the suite is skipped (see beforeEach)
// and FTS seeding below is bypassed so setup never throws.
if (ftsRequired) {
ftsAvailable = await adapter.loadFTSExtension(undefined, {
policy: resolveAnalyzeInstallPolicy(),
});
}
// 2. Drop stale FTS indexes from previous test file
if (options?.ftsIndexes?.length) {
for (const idx of options.ftsIndexes) {
@ -108,8 +128,9 @@ export function withTestLbugDB(
}
}
// 5. Create FTS indexes on fresh data
if (options?.ftsIndexes?.length) {
// 5. Create FTS indexes on fresh data (only when the extension loaded;
// otherwise the suite is skipped via beforeEach below).
if (options?.ftsIndexes?.length && ftsAvailable) {
for (const idx of options.ftsIndexes) {
await adapter.createFTSIndex(idx.table, idx.indexName, idx.columns);
}
@ -166,6 +187,21 @@ export function withTestLbugDB(
// collisions when multiple withTestLbugDB calls share the same file.
describe(`withTestLbugDB(${prefix})`, () => {
beforeAll(setup, timeout);
// Skip FTS-dependent suites when the extension could not be loaded or
// installed on this machine. Without this, tests would assert against a
// missing index and fail. Warn once so the skip is visible, not silent.
beforeEach((ctx) => {
if (ftsRequired && !ftsAvailable) {
if (!ftsSkipWarned) {
ftsSkipWarned = true;
console.warn(
`[withTestLbugDB(${prefix})] Skipping FTS-dependent tests — the LadybugDB ` +
`FTS extension is unavailable (not pre-installed and could not be installed).`,
);
}
ctx.skip();
}
});
// Explicit timeout: KuzuDB's C++ destructor can hang on Windows during
// native resource cleanup. The vitest hookTimeout (120s) should apply
// automatically, but some vitest versions fall back to testTimeout (30s)

View file

@ -23,6 +23,26 @@ import { withTestLbugDB } from '../helpers/test-indexed-db.js';
*/
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
/**
* The FTS extension is optional and defaults to a `load-only` install policy
* (PR #1161 — offline-first), so on a machine where it was never pre-installed
* it cannot load. The tests below exercise the FTS *primitives* directly and
* have nothing to assert without the extension — skip them rather than fail.
* Graceful degradation when FTS is unavailable is covered at the analyze /
* query layer (see run-analyze.ts and the BM25 fallback tests).
*/
const FTS_UNAVAILABLE_NOTE =
'FTS extension unavailable (load-only policy; not pre-installed on this machine)';
/**
* Dynamically skip an FTS-primitive test when the extension cannot load.
* `ctx.skip()` aborts the test, so callers should `await` this first thing.
*/
const skipUnlessFtsAvailable = async (ctx: { skip: (note?: string) => void }): Promise<void> => {
const { loadFTSExtension } = await import('../../src/core/lbug/lbug-adapter.js');
if (!(await loadFTSExtension())) ctx.skip(FTS_UNAVAILABLE_NOTE);
};
// ─── Core LadybugDB Adapter ─────────────────────────────────────────────
withTestLbugDB(
@ -47,7 +67,8 @@ withTestLbugDB(
expect(folderRows).toHaveLength(1);
});
it('createFTSIndex: creates FTS index on Function table without error', async () => {
it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
await expect(
@ -55,7 +76,8 @@ withTestLbugDB(
).resolves.toBeUndefined();
});
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async () => {
it('loadFTSExtension(conn): loads on an explicit connection and returns true', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const lbug = (await import('@ladybugdb/core')).default;
const { loadFTSExtension, getDatabase } =
await import('../../src/core/lbug/lbug-adapter.js');
@ -119,7 +141,8 @@ withTestLbugDB(
});
describe('error handling', () => {
it('createFTSIndex handles already-existing index gracefully', async () => {
it('createFTSIndex handles already-existing index gracefully', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index (may already exist from earlier test)
@ -131,7 +154,8 @@ withTestLbugDB(
).resolves.toBeUndefined();
});
it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async () => {
it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');
// First call creates the index. Second call must short-circuit on the
@ -174,7 +198,8 @@ withTestLbugDB(
itLbugReopen(
'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes',
async () => {
async (ctx) => {
await skipUnlessFtsAvailable(ctx);
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const indexName = 'function_fts_init_probe';

View file

@ -3,6 +3,7 @@ import { createHash } from 'crypto';
import {
contentHashForNode,
EMBEDDING_TEXT_VERSION,
resolveEmbeddingInstallPolicy,
} from '../../src/core/embeddings/embedding-pipeline.js';
import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js';
import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js';
@ -12,6 +13,55 @@ import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js';
const CLASS_CHUNK_SIZE = 90;
const CLASS_OVERLAP = 10;
// ────────────────────────────────────────────────────────────────────────────
// resolveEmbeddingInstallPolicy (offline-first, #1153)
// ────────────────────────────────────────────────────────────────────────────
describe('resolveEmbeddingInstallPolicy (#1153)', () => {
const ENV = 'GITNEXUS_LBUG_EXTENSION_INSTALL';
const original = process.env[ENV];
const restore = () => {
if (original === undefined) delete process.env[ENV];
else process.env[ENV] = original;
};
it('defaults to auto when unset (embeddings are an explicit network-capable opt-in)', () => {
delete process.env[ENV];
try {
expect(resolveEmbeddingInstallPolicy()).toBe('auto');
} finally {
restore();
}
});
it('honors an explicit load-only override (offline operator is not forced onto the network)', () => {
process.env[ENV] = 'load-only';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('load-only');
} finally {
restore();
}
});
it('honors an explicit never override', () => {
process.env[ENV] = 'never';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('never');
} finally {
restore();
}
});
it('falls back to auto for invalid values', () => {
process.env[ENV] = 'bogus';
try {
expect(resolveEmbeddingInstallPolicy()).toBe('auto');
} finally {
restore();
}
});
});
// ────────────────────────────────────────────────────────────────────────────
// contentHashForNode
// ────────────────────────────────────────────────────────────────────────────

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
ExtensionManager,
getExtensionInstallChildProcessArgs,
getExtensionInstallPolicy,
getExtensionInstallTimeoutMs,
type ExtensionInstallResult,
} from '../../src/core/lbug/extension-loader.js';
@ -222,6 +223,64 @@ describe('installDuckDbExtensionOutOfProcess child process', () => {
});
});
describe('getExtensionInstallPolicy', () => {
it('defaults to load-only when env var is unset', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
try {
expect(getExtensionInstallPolicy()).toBe('load-only');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('returns auto when env var is set to auto', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'auto';
try {
expect(getExtensionInstallPolicy()).toBe('auto');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('returns never when env var is set to never', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never';
try {
expect(getExtensionInstallPolicy()).toBe('never');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
it('falls back to load-only for invalid env var values', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'bogus';
try {
expect(getExtensionInstallPolicy()).toBe('load-only');
} finally {
if (original === undefined) {
delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL;
} else {
process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = original;
}
}
});
});
describe('getExtensionInstallTimeoutMs', () => {
it('reads a positive override from the environment', () => {
const original = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS;

View file

@ -19,6 +19,7 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
vi.doUnmock('../../src/core/lbug/lbug-adapter.js');
vi.doUnmock('../../src/core/search/fts-indexes.js');
vi.doUnmock('../../src/core/ingestion/pipeline.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.resetModules();
vi.clearAllMocks();
});
@ -211,6 +212,8 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// FTS extension loads → analyze proceeds to create + verify indexes.
loadFTSExtension: vi.fn(async () => true),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes: vi.fn(async () => undefined),
@ -240,4 +243,66 @@ describe('runFullAnalysis FTS repair and verification failure paths', () => {
await tmpRepo.cleanup();
}
});
it('full analyze degrades gracefully (no throw, warns, skips index creation) when FTS extension is unavailable', async () => {
// Offline-first degradation: when loadFTSExtension() returns false, the
// analyze path must NOT call createSearchFTSIndexes / verifySearchFTSIndexes
// and must NOT throw — it logs a warning and completes (#1161).
const createSearchFTSIndexes = vi.fn(async () => undefined);
const verifySearchFTSIndexes = vi.fn(async () => []);
vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({
initLbug: vi.fn(async () => undefined),
loadGraphToLbug: vi.fn(async () => undefined),
getLbugStats: vi.fn(async () => ({ nodes: 1, edges: 0, communities: 0, processes: 0 })),
executeQuery: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
closeLbug: vi.fn(async () => undefined),
loadCachedEmbeddings: vi.fn(async () => ({ embeddingNodeIds: new Set(), embeddings: [] })),
deleteNodesForFile: vi.fn(async () => undefined),
deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined),
queryImporters: vi.fn(async () => []),
// FTS extension cannot load (offline + not pre-installed, or policy forced).
loadFTSExtension: vi.fn(async () => false),
}));
vi.doMock('../../src/core/search/fts-indexes.js', () => ({
createSearchFTSIndexes,
verifySearchFTSIndexes,
}));
vi.doMock('../../src/core/ingestion/pipeline.js', () => ({
runPipelineFromRepo: vi.fn(async (repoPath: string) => ({
repoPath,
totalFileCount: 1,
graph: { forEachNode: () => undefined },
})),
}));
// Avoid touching the global registry / repo .gitnexusignore from a unit test.
vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
registerRepo: vi.fn(async () => 'degraded-repo'),
ensureGitNexusIgnored: vi.fn(async () => undefined),
}));
const tmpRepo = await createTempDir('gitnexus-run-analyze-fts-degrade-');
try {
const logs: string[] = [];
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const result = await runFullAnalysis(
tmpRepo.dbPath,
{ force: true },
{ onProgress: () => {}, onLog: (msg: string) => logs.push(msg) },
);
expect(result.ftsSkipped).toBe(true);
expect(createSearchFTSIndexes).not.toHaveBeenCalled();
expect(verifySearchFTSIndexes).not.toHaveBeenCalled();
expect(logs.join('\n')).toMatch(/FTS extension unavailable; skipping search-index creation/i);
// The degraded state is persisted so meta.json / doctor stay honest.
const { storagePath } = getStoragePaths(tmpRepo.dbPath);
const meta = JSON.parse(await fs.readFile(`${storagePath}/meta.json`, 'utf-8'));
expect(meta.capabilities.fts.status).toBe('unavailable');
} finally {
await tmpRepo.cleanup();
}
});
});

View file

@ -15,7 +15,11 @@
import { describe, it, expect, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMCPServer } from '../../src/mcp/server.js';
import {
createMCPServer,
installSignalShutdown,
SHUTDOWN_EXIT_CODES,
} from '../../src/mcp/server.js';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
// ─── Mock backend ──────────────────────────────────────────────────
@ -125,3 +129,38 @@ describe('prompt registration', () => {
expect(server).toBeDefined();
});
});
// ─── Graceful shutdown signal handling (#1132) ────────────────────────
describe('installSignalShutdown (#1132)', () => {
it('maps SIGINT→130 / SIGTERM→143 and never passes the signal name to shutdown', () => {
// Node invokes signal listeners with the signal NAME string as the first
// argument. The old code registered `shutdown` directly, so that string
// reached process.exit() and crashed with ERR_INVALID_ARG_TYPE. Reproduce
// that exact invocation and assert a numeric code is used instead.
const received: unknown[] = [];
let onSigint: ((...args: unknown[]) => void) | undefined;
let onSigterm: ((...args: unknown[]) => void) | undefined;
installSignalShutdown(
(code) => received.push(code),
(event, listener) => {
if (event === 'SIGINT') onSigint = listener;
if (event === 'SIGTERM') onSigterm = listener;
},
);
expect(onSigint).toBeTypeOf('function');
expect(onSigterm).toBeTypeOf('function');
// Invoke exactly as Node does — with the signal name string as the arg.
onSigint?.('SIGINT');
onSigterm?.('SIGTERM');
expect(received).toEqual([SHUTDOWN_EXIT_CODES.SIGINT, SHUTDOWN_EXIT_CODES.SIGTERM]);
expect(received).toEqual([130, 143]);
for (const code of received) {
expect(typeof code).toBe('number');
}
});
});