diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 6ea2d43772..227b929a1d 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 @@ -1191,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. 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/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index a45471fc11..fb83d2e5cd 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 platform = opts.platform ?? process.platform; + const arch = opts.arch ?? process.arch; + const blocker = getLocalEmbeddingRuntimeBlocker({ platform, arch }); + if (blocker) { + 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/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..e0a6c28d97 --- /dev/null +++ b/gitnexus/src/core/embeddings/runtime-support.ts @@ -0,0 +1,79 @@ +/** + * 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. 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).', + ' - 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/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/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(); + }); +}); 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..5203510ecb --- /dev/null +++ b/gitnexus/test/unit/embedding-runtime-support.test.ts @@ -0,0 +1,272 @@ +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/); + // Addresses the GitNexus device knob too, not only ONNX_WEB_BACKEND (R3 / #1987) + expect(text).toContain('GITNEXUS_EMBEDDING_DEVICE'); + }); + + 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(); + } + }); +}); + +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(); + } + }); +}); + +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(); + } + }); +});