From 0668f5d0dfeac092890bde2a14613de7321dc28d Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 07:03:34 +0000 Subject: [PATCH 1/3] fix(embeddings): guard local ONNX runtime on macOS Intel before transformers.js import macOS Intel (darwin/x64) crashed on `gitnexus analyze --embeddings` with a raw `Cannot find module .../bin/napi-v6/darwin/x64/onnxruntime_binding.node`: both embedders imported @huggingface/transformers at module scope, which loads onnxruntime-node and resolves the (unshipped) native binding before any backend could be selected. ONNX_WEB_BACKEND=wasm could not help (#1516). - Add a native-free runtime-support guard (getLocalEmbeddingRuntimeBlocker) that returns a clear, actionable message on darwin/x64 and null elsewhere. - Convert both the core and MCP embedders to type-only transformers imports plus a guarded lazy `await import()`; throw the blocker in initEmbedder before any transformers.js / onnxruntime-node resolution. HTTP mode is unaffected. - Surface the blocker cleanly in the analyze CLI instead of the misleading "installation may be corrupt" module-not-found hint. - Add unit tests: guard DI, lazy-import timing, core+MCP darwin/x64 rejection, and HTTP mode not blocked. Refs #1515, #1516 Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/cli/analyze.ts | 14 ++ gitnexus/src/cli/cli-message.ts | 1 + gitnexus/src/core/embeddings/embedder.ts | 27 ++- .../src/core/embeddings/runtime-support.ts | 78 +++++++ gitnexus/src/mcp/core/embedder.ts | 21 +- .../unit/embedding-runtime-support.test.ts | 212 ++++++++++++++++++ 6 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 gitnexus/src/core/embeddings/runtime-support.ts create mode 100644 gitnexus/test/unit/embedding-runtime-support.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 6ea2d43772..c0f9d9b3a2 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -35,6 +35,7 @@ import fs from 'fs/promises'; import { cliError } from './cli-message.js'; import { formatElapsed } from './format-elapsed.js'; import { isHfDownloadFailure } from '../core/embeddings/hf-env.js'; +import { isLocalEmbeddingRuntimeBlockerMessage } from '../core/embeddings/runtime-support.js'; import { warnIfNpm11NpxRisk } from './resolve-invocation.js'; // Capture stderr.write at module load BEFORE anything (LadybugDB native @@ -1211,6 +1212,19 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): return; } + // Local embedding runtime unsupported on this platform (macOS Intel ships no + // darwin/x64 ONNX native binding, #1515). The guard threw before importing + // transformers.js, so this is a clean, actionable GitNexus message — present + // it as such instead of a raw stack trace plus the misleading "installation + // may be corrupt" module-not-found hint below. + if (isLocalEmbeddingRuntimeBlockerMessage(msg)) { + cliError(` ${msg.replace(/\n/g, '\n ')}\n`, { + recoveryHint: 'local-embedding-unsupported', + }); + process.exitCode = 1; + return; + } + // Bypass the redirected console.error and write the full stack to // the real stderr captured at module load. The redirected // console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line) diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index 87b3fa74f4..d21bb536ef 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -49,6 +49,7 @@ export type RecoveryHint = | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' + | 'local-embedding-unsupported' | 'large-repo' | 'npm-resolution' | 'module-not-found'; diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index d2e9d0aff2..d873f3a0a3 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -14,12 +14,11 @@ if (!process.env.ORT_LOG_LEVEL) { process.env.ORT_LOG_LEVEL = '3'; } -import { - pipeline, - env, - type FeatureExtractionPipeline, - type ProgressInfo, -} from '@huggingface/transformers'; +// Type-only import: erased at compile time so loading this module never pulls +// in @huggingface/transformers (and its native onnxruntime-node binding) at +// runtime. The runtime values (pipeline, env) are dynamically imported inside +// initEmbedder, after the platform guard has passed (#1515). +import type { FeatureExtractionPipeline, ProgressInfo } from '@huggingface/transformers'; import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; @@ -28,6 +27,7 @@ import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } fr import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; import { resolveEmbeddingConfig } from './config.js'; import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js'; +import { getLocalEmbeddingRuntimeBlocker } from './runtime-support.js'; import { logger } from '../logger.js'; /** @@ -143,6 +143,17 @@ export const initEmbedder = async ( ); } + // Fail fast on platforms where the bundled native ONNX Runtime binding is not + // shipped (macOS Intel, #1515). Must run before any transformers.js / + // onnxruntime-node import or resolution — otherwise the native module load + // crashes with a raw "Cannot find module ...onnxruntime_binding.node" that + // ONNX_WEB_BACKEND=wasm cannot rescue (#1516). HTTP mode was already handled + // above, so this only blocks the local-runtime path. + const runtimeBlocker = getLocalEmbeddingRuntimeBlocker(); + if (runtimeBlocker) { + throw new Error(runtimeBlocker); + } + // Return existing instance if available if (embedderInstance) { return embedderInstance; @@ -166,6 +177,10 @@ export const initEmbedder = async ( initPromise = (async () => { try { + // Lazy-load transformers.js only after the runtime guard has passed, so + // unsupported platforms never reach the native ONNX import (#1515). + const { pipeline, env } = await import('@huggingface/transformers'); + // Configure transformers.js environment env.allowLocalModels = false; // Bridge user-controlled env vars to transformers.js: HF_HOME → diff --git a/gitnexus/src/core/embeddings/runtime-support.ts b/gitnexus/src/core/embeddings/runtime-support.ts new file mode 100644 index 0000000000..94c25af6fb --- /dev/null +++ b/gitnexus/src/core/embeddings/runtime-support.ts @@ -0,0 +1,78 @@ +/** + * Local embedding runtime support guard. + * + * The bundled local embedding stack (`@huggingface/transformers` → + * `onnxruntime-node`) only ships native ONNX Runtime bindings for a subset of + * platform/arch pairs. On macOS Intel (`darwin`/`x64`), `onnxruntime-node` + * ships no `bin/napi-v6/darwin/x64/onnxruntime_binding.node`, so *importing* + * transformers.js throws a raw `Cannot find module ...onnxruntime_binding.node` + * before any device/backend selection can run (#1515). `ONNX_WEB_BACKEND=wasm` + * cannot rescue this — the failure is at native-module import time, not backend + * selection (#1516). + * + * This module is intentionally free of any native or transformers.js import (at + * module scope or inside its functions) so it can be consulted *before* the + * dynamic import that would crash. HTTP embedding mode never touches the native + * runtime, so callers in HTTP mode must skip this guard. + */ + +/** + * Stable lead line of the macOS-Intel blocker message. Also used to recognise + * the thrown error in the CLI error handler without coupling to the full + * wording (see {@link isLocalEmbeddingRuntimeBlockerMessage}). + */ +const LOCAL_EMBEDDING_BLOCKER_LEAD = + 'Local semantic embeddings are unavailable on macOS Intel (darwin/x64).'; + +export interface LocalEmbeddingRuntimeOptions { + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; +} + +/** + * Return a human-readable explanation when the *local* embedding runtime cannot + * load on this platform, or `null` when local embeddings are expected to work. + * + * Only `darwin`/`x64` is blocked today: it is the one platform/arch pair where + * the bundled `onnxruntime-node` ships no native binding (#1515). Every other + * platform returns `null` and follows the normal device-probe path, so genuine + * ONNX failures on supported platforms are never masked by this message. + * + * Accepts an explicit `{ platform, arch }` for testing; defaults to the current + * process values. + */ +export const getLocalEmbeddingRuntimeBlocker = ( + options: LocalEmbeddingRuntimeOptions = {}, +): string | null => { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + + if (platform === 'darwin' && arch === 'x64') { + return [ + LOCAL_EMBEDDING_BLOCKER_LEAD, + 'The bundled ONNX Runtime package (onnxruntime-node) does not ship a', + 'darwin/x64 native binding, so the local embedding model cannot load here.', + 'ONNX_WEB_BACKEND=wasm does not help: the failure happens while importing', + 'the native runtime, before any backend can be selected.', + '', + 'Use one of these instead:', + ' - Run analyze without --embeddings (all other indexing still works).', + ' - Point GITNEXUS_EMBEDDING_URL (with GITNEXUS_EMBEDDING_MODEL) at an', + ' OpenAI-compatible /v1/embeddings endpoint to embed over HTTP.', + ' - Run GitNexus on Linux or in Docker, where the native binding ships.', + ' - Run GitNexus on Apple Silicon (darwin/arm64), which ships a binding.', + ' - Use a future GitNexus build that restores darwin/x64 ONNX support.', + ].join('\n'); + } + + return null; +}; + +/** + * True when `message` is the macOS-Intel local-embedding blocker produced by + * {@link getLocalEmbeddingRuntimeBlocker}. Lets the CLI surface a clean, + * actionable message instead of a raw stack trace, without coupling to the + * full wording. + */ +export const isLocalEmbeddingRuntimeBlockerMessage = (message: string): boolean => + message.includes(LOCAL_EMBEDDING_BLOCKER_LEAD); diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index f529f28054..451d12c1f8 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -5,7 +5,11 @@ * For MCP, we only need to compute query embeddings, not batch embed. */ -import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; +// Type-only import: erased at compile time so loading this module never pulls +// in @huggingface/transformers (and its native onnxruntime-node binding) at +// runtime. The runtime values (pipeline, env) are dynamically imported inside +// initEmbedder, after the platform guard has passed (#1515). +import type { FeatureExtractionPipeline } from '@huggingface/transformers'; import { isHttpMode, getHttpDimensions, @@ -17,6 +21,7 @@ import { isHfDownloadFailure, withHfDownloadRetry, } from '../../core/embeddings/hf-env.js'; +import { getLocalEmbeddingRuntimeBlocker } from '../../core/embeddings/runtime-support.js'; import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js'; import { logger } from '../../core/logger.js'; @@ -36,6 +41,16 @@ export const initEmbedder = async (): Promise => { throw new Error('initEmbedder() should not be called in HTTP mode.'); } + // Fail fast on platforms where the bundled native ONNX Runtime binding is not + // shipped (macOS Intel, #1515). Must run before any transformers.js / + // onnxruntime-node import or resolution — otherwise the native module load + // crashes with a raw "Cannot find module ...onnxruntime_binding.node" that + // ONNX_WEB_BACKEND=wasm cannot rescue (#1516). + const runtimeBlocker = getLocalEmbeddingRuntimeBlocker(); + if (runtimeBlocker) { + throw new Error(runtimeBlocker); + } + if (embedderInstance) { return embedderInstance; } @@ -48,6 +63,10 @@ export const initEmbedder = async (): Promise => { initPromise = (async () => { try { + // Lazy-load transformers.js only after the runtime guard has passed, so + // unsupported platforms never reach the native ONNX import (#1515). + const { pipeline, env } = await import('@huggingface/transformers'); + env.allowLocalModels = false; // Bridge user-controlled env vars to transformers.js: HF_HOME → // env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in diff --git a/gitnexus/test/unit/embedding-runtime-support.test.ts b/gitnexus/test/unit/embedding-runtime-support.test.ts new file mode 100644 index 0000000000..3b678cea58 --- /dev/null +++ b/gitnexus/test/unit/embedding-runtime-support.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + getLocalEmbeddingRuntimeBlocker, + isLocalEmbeddingRuntimeBlockerMessage, +} from '../../src/core/embeddings/runtime-support.js'; + +/** + * Spy that fires whenever @huggingface/transformers is actually imported. + * Hoisted so the vi.mock factory below can reference it. The mock replaces the + * real module entirely, so this suite never loads onnxruntime-node — it is safe + * to run on any platform, including hosts without the native binding. + */ +const { transformersImported } = vi.hoisted(() => ({ transformersImported: vi.fn() })); + +vi.mock('@huggingface/transformers', () => { + transformersImported(); + const fakePipeline: any = async () => ({ data: new Float32Array(384) }); + return { + pipeline: vi.fn(async () => fakePipeline), + env: { allowLocalModels: true, cacheDir: '', remoteHost: '' }, + }; +}); + +const EMBED_ENV_KEYS = [ + 'GITNEXUS_EMBEDDING_URL', + 'GITNEXUS_EMBEDDING_MODEL', + 'GITNEXUS_EMBEDDING_API_KEY', + 'GITNEXUS_EMBEDDING_DIMS', +] as const; + +const savedEnv = Object.fromEntries(EMBED_ENV_KEYS.map((k) => [k, process.env[k]])); + +/** Stub process.platform/arch via DI-friendly defineProperty; returns a restore fn. */ +const stubPlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture): (() => void) => { + const orig = { platform: process.platform, arch: process.arch }; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + Object.defineProperty(process, 'arch', { value: arch, configurable: true }); + return () => { + Object.defineProperty(process, 'platform', { value: orig.platform, configurable: true }); + Object.defineProperty(process, 'arch', { value: orig.arch, configurable: true }); + }; +}; + +beforeEach(() => { + vi.resetModules(); + transformersImported.mockClear(); + for (const key of EMBED_ENV_KEYS) delete process.env[key]; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + for (const key of EMBED_ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +describe('getLocalEmbeddingRuntimeBlocker', () => { + it('blocks darwin/x64 (macOS Intel)', () => { + expect(getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'x64' })).not.toBeNull(); + }); + + it('returns null for darwin/arm64, linux/x64, and win32/x64', () => { + expect(getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'arm64' })).toBeNull(); + expect(getLocalEmbeddingRuntimeBlocker({ platform: 'linux', arch: 'x64' })).toBeNull(); + expect(getLocalEmbeddingRuntimeBlocker({ platform: 'win32', arch: 'x64' })).toBeNull(); + }); + + it('explains macOS Intel, local embeddings, the ONNX native binding, and safe alternatives', () => { + const msg = getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'x64' }); + expect(msg).not.toBeNull(); + const text = msg as string; + // What failed + expect(text).toMatch(/macOS Intel/); + expect(text).toMatch(/local semantic embeddings/i); + expect(text).toMatch(/ONNX/); + expect(text).toMatch(/native binding/i); + // Does NOT imply wasm rescues it, and does NOT leak the raw native error + expect(text).toMatch(/wasm does not help/i); + expect(text).not.toMatch(/Cannot find module/); + // Safe alternatives + expect(text).toMatch(/without --embeddings/); + expect(text).toContain('GITNEXUS_EMBEDDING_URL'); + expect(text).toMatch(/Linux or in Docker/); + expect(text).toMatch(/Apple Silicon/); + }); + + it('defaults platform/arch to the current process when no options are given', () => { + // On the linux/x64 CI host this is null; the value must equal the explicit form. + expect(getLocalEmbeddingRuntimeBlocker()).toBe( + getLocalEmbeddingRuntimeBlocker({ platform: process.platform, arch: process.arch }), + ); + }); +}); + +describe('isLocalEmbeddingRuntimeBlockerMessage', () => { + it('recognises the blocker message and rejects unrelated errors', () => { + const blocker = getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'x64' }) as string; + expect(isLocalEmbeddingRuntimeBlockerMessage(blocker)).toBe(true); + expect(isLocalEmbeddingRuntimeBlockerMessage('ECONNREFUSED while downloading model')).toBe( + false, + ); + expect( + isLocalEmbeddingRuntimeBlockerMessage( + "Cannot find module '../bin/.../onnxruntime_binding.node'", + ), + ).toBe(false); + }); +}); + +describe('lazy transformers.js import', () => { + it('control: the spy fires when transformers.js is actually imported', async () => { + expect(transformersImported).not.toHaveBeenCalled(); + await import('@huggingface/transformers'); + expect(transformersImported).toHaveBeenCalled(); + }); + + it('importing the guard module does not import transformers.js', async () => { + await import('../../src/core/embeddings/runtime-support.js'); + expect(transformersImported).not.toHaveBeenCalled(); + }); + + it('importing the core embedder does not import transformers.js at module load', async () => { + await import('../../src/core/embeddings/embedder.js'); + expect(transformersImported).not.toHaveBeenCalled(); + }); + + it('importing the MCP embedder does not import transformers.js at module load', async () => { + await import('../../src/mcp/core/embedder.js'); + expect(transformersImported).not.toHaveBeenCalled(); + }); +}); + +describe('initEmbedder local-runtime guard (darwin/x64)', () => { + it('rejects the core initEmbedder before importing transformers.js', async () => { + const restore = stubPlatform('darwin', 'x64'); + try { + const { initEmbedder } = await import('../../src/core/embeddings/embedder.js'); + await expect(initEmbedder()).rejects.toThrow(/macOS Intel/); + // The guard must short-circuit before the lazy transformers.js import. + expect(transformersImported).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('rejects with a clean GitNexus message, not the raw native module error', async () => { + const restore = stubPlatform('darwin', 'x64'); + try { + const { initEmbedder } = await import('../../src/core/embeddings/embedder.js'); + const err = (await initEmbedder().catch((e) => e)) as Error; + expect(err.message).toMatch(/native binding/i); + expect(err.message).not.toMatch(/Cannot find module/); + expect(err.message).not.toMatch(/onnxruntime_binding/); + } finally { + restore(); + } + }); + + it('rejects the MCP initEmbedder before importing transformers.js', async () => { + const restore = stubPlatform('darwin', 'x64'); + try { + const { initEmbedder } = await import('../../src/mcp/core/embedder.js'); + await expect(initEmbedder()).rejects.toThrow(/macOS Intel/); + expect(transformersImported).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe('HTTP embedding mode on darwin/x64', () => { + it('is not blocked by the local-runtime guard and never touches the native runtime', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + const mockVec = Array.from({ length: 384 }, (_, i) => i / 384); + // Size the response to the request's `input` length so both the single + // (embedText) and batched (embedBatch) calls get matching vector counts. + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: { body: string }) => { + const n = (JSON.parse(init.body) as { input: string[] }).input.length; + return { + ok: true, + json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: mockVec })) }), + }; + }), + ); + + const restore = stubPlatform('darwin', 'x64'); + try { + const { embedText, embedBatch, isEmbedderReady } = + await import('../../src/core/embeddings/embedder.js'); + + // HTTP mode is ready without any local/native initialization. + expect(isEmbedderReady()).toBe(true); + + const single = await embedText('hello from macOS Intel'); + expect(single).toBeInstanceOf(Float32Array); + expect(single.length).toBe(384); + + const batch = await embedBatch(['a', 'b']); + expect(batch).toHaveLength(2); + + // HTTP embeddings must route through fetch, never the local ONNX runtime. + expect(fetch).toHaveBeenCalled(); + expect(transformersImported).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); From 1ddf93729188753aafca04dd4f048432ac9c2cf0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 07:10:57 +0000 Subject: [PATCH 2/3] fix(doctor): surface macOS Intel local-embedding limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gitnexus doctor` now reports whether the local embedding runtime can load on the current platform. macOS Intel (darwin/x64) users see up front that local embeddings are unavailable — plus the recommended alternatives — instead of only discovering it when `analyze --embeddings` fails (#1515). The Embeddings section gains a "Support" line; on a blocked platform the full guidance (reused from getLocalEmbeddingRuntimeBlocker, single source of truth) is written to stderr. doctor stays import-safe — it never loads transformers.js or onnxruntime-node, so it runs cleanly on macOS Intel. Refs #1515 Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/cli/doctor.ts | 37 +++++++++++++++++++++++ gitnexus/test/unit/doctor-format.test.ts | 38 +++++++++++++++++++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index a45471fc11..8ebe222d1d 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -1,6 +1,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; import { isHttpMode } from '../core/embeddings/http-client.js'; +import { getLocalEmbeddingRuntimeBlocker } from '../core/embeddings/runtime-support.js'; import { checkLbugNative } from '../core/lbug/native-check.js'; import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js'; import { t } from './i18n/index.js'; @@ -50,6 +51,33 @@ export function padDisplayEnd(value: string, columns: number): string { const label = (key: Parameters[0], width: number): string => padDisplayEnd(t(key), width); +/** + * Embedding-runtime support status for the `doctor` Embeddings section. + * Pure and DI-friendly so it can be unit-tested without running the whole + * command. Delegates the platform decision to + * {@link getLocalEmbeddingRuntimeBlocker} so the wording stays in one place. + * + * - HTTP mode: always supported (never touches the native runtime). + * - Local mode on an unsupported platform (macOS Intel, #1515): reports the + * blocker as `detail` so the caller can surface the full guidance. + */ +export function localEmbeddingDoctorStatus(opts: { + httpMode: boolean; + platform?: NodeJS.Platform; + arch?: NodeJS.Architecture; +}): { status: string; detail: string | null } { + if (opts.httpMode) { + return { status: '✓ http endpoint configured', detail: null }; + } + const blocker = getLocalEmbeddingRuntimeBlocker({ platform: opts.platform, arch: opts.arch }); + if (blocker) { + const platform = opts.platform ?? process.platform; + const arch = opts.arch ?? process.arch; + return { status: `✗ local embeddings unavailable on ${platform}/${arch}`, detail: blocker }; + } + return { status: '✓ local embeddings supported', detail: null }; +} + export const doctorCommand = async () => { const fingerprint = getRuntimeFingerprint(); const capabilities = getRuntimeCapabilities(); @@ -102,4 +130,13 @@ export const doctorCommand = async () => { console.log( ` ${label('doctor.labels.subBatch', 12)}${t('doctor.chunks', { count: embeddingConfig.subBatchSize })}`, ); + // Surface local-runtime support so macOS Intel users see up front that local + // embeddings can't load here (the bundled ONNX Runtime ships no darwin/x64 + // native binding, #1515) — rather than discovering it only when + // `analyze --embeddings` fails. Literal label like the 'native' line above. + const support = localEmbeddingDoctorStatus({ httpMode: isHttpMode() }); + console.log(` ${padDisplayEnd('Support:', 12)}${support.status}`); + if (support.detail) { + process.stderr.write(`\n${support.detail.replace(/^/gm, ' ')}\n\n`); + } }; diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 1995458852..259061ce97 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { displayWidth, padDisplayEnd } from '../../src/cli/doctor.js'; +import { displayWidth, localEmbeddingDoctorStatus, padDisplayEnd } from '../../src/cli/doctor.js'; describe('doctor output formatting', () => { it('keeps ASCII padding equivalent to String.padEnd', () => { @@ -19,3 +19,39 @@ describe('doctor output formatting', () => { expect(padDisplayEnd('图存储:', 4)).toBe('图存储:'); }); }); + +describe('doctor embedding-runtime support status', () => { + it('flags local embeddings as unavailable on macOS Intel (darwin/x64)', () => { + const { status, detail } = localEmbeddingDoctorStatus({ + httpMode: false, + platform: 'darwin', + arch: 'x64', + }); + expect(status).toBe('✗ local embeddings unavailable on darwin/x64'); + expect(detail).not.toBeNull(); + expect(detail).toMatch(/macOS Intel/); + expect(detail).toMatch(/native binding/i); + }); + + it('reports local embeddings as supported on darwin/arm64, linux/x64, and win32/x64', () => { + for (const [platform, arch] of [ + ['darwin', 'arm64'], + ['linux', 'x64'], + ['win32', 'x64'], + ] as Array<[NodeJS.Platform, NodeJS.Architecture]>) { + const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform, arch }); + expect(status).toBe('✓ local embeddings supported'); + expect(detail).toBeNull(); + } + }); + + it('reports HTTP backend as configured and never blocks on platform', () => { + const { status, detail } = localEmbeddingDoctorStatus({ + httpMode: true, + platform: 'darwin', + arch: 'x64', + }); + expect(status).toBe('✓ http endpoint configured'); + expect(detail).toBeNull(); + }); +}); From f7a47d484858aa9ffede9faf48dc4456e4cf6a99 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 08:20:01 +0000 Subject: [PATCH 3/3] test(embeddings): close #1515 guard coverage gaps + PR #1987 review polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the maintainer tri-review feedback on PR #1987: - Add the analyze error-branch test (new analyze-local-embedding-error.test.ts): a darwin/x64 blocker routes to the clean local-embedding-unsupported message (exit 1), not the module-not-found "installation may be corrupt" branch, and wins over isHfDownloadFailure even when both match (guards the reorder below). - Cover the MCP embedQuery darwin/x64 paths — HTTP bypass via httpEmbedQuery without importing transformers, and local-mode rejection before the import. - Make the "defaults platform/arch" guard test falsifiable by stubbing the platform, instead of asserting null === null on the CI host. - analyze.ts: evaluate the blocker-message branch before the network-heuristic isHfDownloadFailure branch so the explicit platform message takes priority. - runtime-support.ts: the blocker message now also notes GITNEXUS_EMBEDDING_DEVICE =wasm/cpu cannot help, not only ONNX_WEB_BACKEND=wasm. - doctor.ts: resolve platform/arch once instead of re-resolving after the guard. Refs #1515, #1516 Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/cli/analyze.ts | 27 ++-- gitnexus/src/cli/doctor.ts | 6 +- .../src/core/embeddings/runtime-support.ts | 3 +- .../analyze-local-embedding-error.test.ts | 151 ++++++++++++++++++ .../unit/embedding-runtime-support.test.ts | 70 +++++++- 5 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 gitnexus/test/unit/analyze-local-embedding-error.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index c0f9d9b3a2..227b929a1d 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1192,6 +1192,20 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): return; } + // Local embedding runtime unsupported on this platform (macOS Intel ships no + // darwin/x64 ONNX native binding, #1515). The guard threw before importing + // transformers.js, so this is a clean, actionable GitNexus message. Checked + // before the network-heuristic isHfDownloadFailure branch below (and before + // the generic module-not-found "installation may be corrupt" hint) so the + // explicit platform message always takes priority. + if (isLocalEmbeddingRuntimeBlockerMessage(msg)) { + cliError(` ${msg.replace(/\n/g, '\n ')}\n`, { + recoveryHint: 'local-embedding-unsupported', + }); + process.exitCode = 1; + return; + } + // HF download failure — show clean guidance without the raw stack trace. // Checked before writeFatalToStderr so the user sees one focused message // rather than a stack-trace dump followed by a second remediation block. @@ -1212,19 +1226,6 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): return; } - // Local embedding runtime unsupported on this platform (macOS Intel ships no - // darwin/x64 ONNX native binding, #1515). The guard threw before importing - // transformers.js, so this is a clean, actionable GitNexus message — present - // it as such instead of a raw stack trace plus the misleading "installation - // may be corrupt" module-not-found hint below. - if (isLocalEmbeddingRuntimeBlockerMessage(msg)) { - cliError(` ${msg.replace(/\n/g, '\n ')}\n`, { - recoveryHint: 'local-embedding-unsupported', - }); - process.exitCode = 1; - return; - } - // Bypass the redirected console.error and write the full stack to // the real stderr captured at module load. The redirected // console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line) diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 8ebe222d1d..fb83d2e5cd 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -69,10 +69,10 @@ export function localEmbeddingDoctorStatus(opts: { if (opts.httpMode) { return { status: '✓ http endpoint configured', detail: null }; } - const blocker = getLocalEmbeddingRuntimeBlocker({ platform: opts.platform, arch: opts.arch }); + const platform = opts.platform ?? process.platform; + const arch = opts.arch ?? process.arch; + const blocker = getLocalEmbeddingRuntimeBlocker({ platform, arch }); if (blocker) { - const platform = opts.platform ?? process.platform; - const arch = opts.arch ?? process.arch; return { status: `✗ local embeddings unavailable on ${platform}/${arch}`, detail: blocker }; } return { status: '✓ local embeddings supported', detail: null }; diff --git a/gitnexus/src/core/embeddings/runtime-support.ts b/gitnexus/src/core/embeddings/runtime-support.ts index 94c25af6fb..e0a6c28d97 100644 --- a/gitnexus/src/core/embeddings/runtime-support.ts +++ b/gitnexus/src/core/embeddings/runtime-support.ts @@ -53,7 +53,8 @@ export const getLocalEmbeddingRuntimeBlocker = ( 'The bundled ONNX Runtime package (onnxruntime-node) does not ship a', 'darwin/x64 native binding, so the local embedding model cannot load here.', 'ONNX_WEB_BACKEND=wasm does not help: the failure happens while importing', - 'the native runtime, before any backend can be selected.', + 'the native runtime, before any backend can be selected. Forcing', + 'GITNEXUS_EMBEDDING_DEVICE=wasm (or cpu) does not help either, for the same reason.', '', 'Use one of these instead:', ' - Run analyze without --embeddings (all other indexing still works).', diff --git a/gitnexus/test/unit/analyze-local-embedding-error.test.ts b/gitnexus/test/unit/analyze-local-embedding-error.test.ts new file mode 100644 index 0000000000..0b5e5de489 --- /dev/null +++ b/gitnexus/test/unit/analyze-local-embedding-error.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for the local-embedding-runtime blocker error path in the + * `analyzeCommand` CLI (#1515 / #1987 review follow-up). + * + * On macOS Intel (darwin/x64) `initEmbedder` throws a GitNexus-authored blocker + * before importing transformers.js. The analyze error handler must route that + * message to a clean `local-embedding-unsupported` message (exit 1) — not the + * generic MODULE_NOT_FOUND "installation may be corrupt" hint, and not the + * network-heuristic HF-download branch — so the explicit platform message wins. + * + * Mirrors the shape of analyze-wal-error.test.ts: + * - vi.mock the heavy dependencies so no real DB / git is touched + * - drive `analyzeCommand` with a mocked `runFullAnalysis` that rejects + * - assert on process.exitCode and the captured logger records + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getLocalEmbeddingRuntimeBlocker } from '../../src/core/embeddings/runtime-support.js'; + +const runFullAnalysisMock = vi.fn(); +// Controllable so the dual-match scenario can force the network heuristic to +// also match the blocker error and prove the blocker branch still wins. +const isHfDownloadFailureMock = vi.fn(() => false); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +// analyze.ts imports isHfDownloadFailure from hf-env.js, which transitively +// pulls gitnexus-shared. Mock it to break the chain and to drive the +// blocker-vs-HF ordering test below. isLocalEmbeddingRuntimeBlockerMessage +// (runtime-support.js) is intentionally NOT mocked — the real branch must fire. +vi.mock('../../src/core/embeddings/hf-env.js', () => ({ + isHfDownloadFailure: isHfDownloadFailureMock, +})); + +const blockerMessage = getLocalEmbeddingRuntimeBlocker({ + platform: 'darwin', + arch: 'x64', +}) as string; + +describe('analyzeCommand local-embedding-runtime error handling', () => { + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + isHfDownloadFailureMock.mockReset(); + isHfDownloadFailureMock.mockReturnValue(false); + process.exitCode = undefined; + // Ensure ensureHeap() short-circuits (heap already at target size) + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + it('routes the blocker to a clean local-embedding-unsupported message (exit 1)', async () => { + runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + expect(process.exitCode).toBe(1); + + const records = cap.records(); + const blockerRecord = records.find((r) => r.recoveryHint === 'local-embedding-unsupported'); + expect(blockerRecord).toBeDefined(); + expect(typeof blockerRecord?.msg === 'string' && blockerRecord.msg).toMatch(/macOS Intel/); + + cap.restore(); + }); + + it('does NOT fall through to the module-not-found "installation may be corrupt" hint', async () => { + runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + const records = cap.records(); + const corruptRecord = records.find( + (r) => typeof r.msg === 'string' && r.msg.includes('installation may be corrupt'), + ); + expect(corruptRecord).toBeUndefined(); + + cap.restore(); + }); + + it('wins over the HF-download branch even when isHfDownloadFailure also matches (R4 ordering)', async () => { + // Force the network heuristic to claim the blocker error too. Because the + // blocker check is ordered before isHfDownloadFailure, the blocker branch + // must still win — this is the only scenario that falsifies a wrong order. + isHfDownloadFailureMock.mockReturnValue(true); + runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + expect(process.exitCode).toBe(1); + + const records = cap.records(); + expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(true); + // The HF-download branch must NOT have fired. + expect(records.some((r) => r.recoveryHint === 'hf-endpoint-unreachable')).toBe(false); + + cap.restore(); + }); + + it('does NOT route unrelated errors through the local-embedding branch', async () => { + runFullAnalysisMock.mockRejectedValue( + new Error('Some unexpected failure unrelated to embeddings'), + ); + + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + expect(process.exitCode).toBe(1); + + const records = cap.records(); + expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(false); + + cap.restore(); + }); +}); diff --git a/gitnexus/test/unit/embedding-runtime-support.test.ts b/gitnexus/test/unit/embedding-runtime-support.test.ts index 3b678cea58..5203510ecb 100644 --- a/gitnexus/test/unit/embedding-runtime-support.test.ts +++ b/gitnexus/test/unit/embedding-runtime-support.test.ts @@ -83,13 +83,30 @@ describe('getLocalEmbeddingRuntimeBlocker', () => { expect(text).toContain('GITNEXUS_EMBEDDING_URL'); expect(text).toMatch(/Linux or in Docker/); expect(text).toMatch(/Apple Silicon/); + // Addresses the GitNexus device knob too, not only ONNX_WEB_BACKEND (R3 / #1987) + expect(text).toContain('GITNEXUS_EMBEDDING_DEVICE'); }); - it('defaults platform/arch to the current process when no options are given', () => { - // On the linux/x64 CI host this is null; the value must equal the explicit form. - expect(getLocalEmbeddingRuntimeBlocker()).toBe( - getLocalEmbeddingRuntimeBlocker({ platform: process.platform, arch: process.arch }), - ); + it('reads platform/arch from process when no options are given', () => { + // Stub the process so the no-arg call must consult process.platform/arch — + // this falsifiably exercises the `?? process.platform` / `?? process.arch` + // fallback (a plain null === null on the CI host would not). + const restoreBlocked = stubPlatform('darwin', 'x64'); + try { + expect(getLocalEmbeddingRuntimeBlocker()).not.toBeNull(); + expect(getLocalEmbeddingRuntimeBlocker()).toBe( + getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'x64' }), + ); + } finally { + restoreBlocked(); + } + + const restoreSupported = stubPlatform('linux', 'x64'); + try { + expect(getLocalEmbeddingRuntimeBlocker()).toBeNull(); + } finally { + restoreSupported(); + } }); }); @@ -210,3 +227,46 @@ describe('HTTP embedding mode on darwin/x64', () => { } }); }); + +describe('MCP embedQuery on darwin/x64', () => { + it('routes HTTP mode through httpEmbedQuery without importing transformers.js', async () => { + process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + const mockVec = Array.from({ length: 384 }, (_, i) => i / 384); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ embedding: mockVec }] }), + })), + ); + + const restore = stubPlatform('darwin', 'x64'); + try { + const { embedQuery } = await import('../../src/mcp/core/embedder.js'); + const vec = await embedQuery('query from macOS Intel'); + + // httpEmbedQuery validates against the default 384 dims (no GITNEXUS_EMBEDDING_DIMS + // set), so the reused stub stays 384-length; resize the stub + DIMS together to vary it. + expect(Array.isArray(vec)).toBe(true); + expect(vec).toHaveLength(384); + expect(fetch).toHaveBeenCalled(); + expect(transformersImported).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it('rejects local mode before importing transformers.js', async () => { + // No GITNEXUS_EMBEDDING_* env (cleared in beforeEach) → local mode → embedQuery + // calls initEmbedder, which throws the guard before the lazy transformers import. + const restore = stubPlatform('darwin', 'x64'); + try { + const { embedQuery } = await import('../../src/mcp/core/embedder.js'); + await expect(embedQuery('query from macOS Intel')).rejects.toThrow(/macOS Intel/); + expect(transformersImported).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +});