GitNexus/gitnexus/test/unit/analyze-local-embedding-error.test.ts
Gergő Magyar fca3494807
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(embeddings): guard local ONNX runtime on macOS Intel before transformers.js import (#1987)
* 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) <noreply@anthropic.com>

* fix(doctor): surface macOS Intel local-embedding limitation

`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) <noreply@anthropic.com>

* test(embeddings): close #1515 guard coverage gaps + PR #1987 review polish

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:46:16 +01:00

151 lines
6 KiB
TypeScript

/**
* 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();
});
});