diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 0c8e72e9e..aebfc7cf8 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -291,13 +291,15 @@ export const doctorCommand = async () => { console.log(` ${padDisplayEnd('', 18)}${remedy}`); } } - // Semantic mode follows the probe, not the platform: without a loadable - // VECTOR extension the index can be neither built nor queried, so search is - // really on exact scan no matter what the platform would allow. + // Doctor has no repository target, so this probe can report only whether the + // runtime can load VECTOR. It cannot claim that a repository has built the + // named HNSW index. Keep the capability and repository state distinct. console.log( - ` ${label('doctor.labels.semanticMode', 18)}${ - vectorProbe.loaded ? capabilities.semanticMode : 'exact-scan' - }`, + ` ${label('doctor.labels.semanticMode', 18)}${t( + vectorProbe.loaded + ? 'doctor.vectorCapability.indexUnverified' + : 'doctor.vectorCapability.exactScanOnly', + )}`, ); // Surface the optional-extension install policy so offline users can see // whether analyze/query will reach the network (extension.ladybugdb.com). diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index a560fa23e..3585a6648 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -123,8 +123,10 @@ export const en = { 'doctor.labels.onnx': 'ONNX:', 'doctor.labels.graphStore': 'Graph store:', 'doctor.labels.fullTextSearch': 'Full-text search:', - 'doctor.labels.vectorIndex': 'VECTOR index:', - 'doctor.labels.semanticMode': 'Semantic mode:', + 'doctor.labels.vectorIndex': 'VECTOR extension:', + 'doctor.labels.semanticMode': 'Semantic support:', + 'doctor.vectorCapability.indexUnverified': 'vector-index capable (repository index not checked)', + 'doctor.vectorCapability.exactScanOnly': 'exact-scan only (VECTOR extension unavailable)', 'doctor.labels.exactScanLimit': 'Exact scan limit:', 'doctor.labels.note': 'Note:', 'doctor.labels.backend': 'Backend:', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 536b8ffb9..2f84f4b97 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -119,8 +119,10 @@ export const zhCN = { 'doctor.labels.onnx': 'ONNX:', 'doctor.labels.graphStore': '图存储:', 'doctor.labels.fullTextSearch': '全文搜索:', - 'doctor.labels.vectorIndex': '向量索引:', - 'doctor.labels.semanticMode': '语义模式:', + 'doctor.labels.vectorIndex': 'VECTOR 扩展:', + 'doctor.labels.semanticMode': '语义支持:', + 'doctor.vectorCapability.indexUnverified': '支持向量索引(未检查仓库索引)', + 'doctor.vectorCapability.exactScanOnly': '仅精确扫描(VECTOR 扩展不可用)', 'doctor.labels.exactScanLimit': '精确扫描上限:', 'doctor.labels.note': '说明:', 'doctor.labels.backend': '后端:', diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 61c0ddb10..6c9da7752 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -8,8 +8,20 @@ import { pageSizeDoctorLines, poolSizeDoctorLine, } from '../../src/cli/doctor.js'; +import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; import type { NativeCheckResult } from '../../src/core/lbug/native-check.js'; +const nativeProbeState = vi.hoisted(() => ({ vectorLoaded: true })); + +vi.mock('../../src/core/lbug/native-check.js', () => ({ + checkLbugNative: () => ({ ok: true, binaryPath: '/synthetic/lbugjs.node' }), + probeFtsExtensionLoad: async () => ({ loaded: true }), + probeVectorExtensionLoad: async () => + nativeProbeState.vectorLoaded + ? { loaded: true } + : { loaded: false, reason: 'synthetic VECTOR load failure' }, +})); + describe('doctor output formatting', () => { it('keeps ASCII padding equivalent to String.padEnd', () => { expect(displayWidth('OS:')).toBe(3); @@ -29,6 +41,67 @@ describe('doctor output formatting', () => { }); }); +describe('doctor VECTOR capability claims', () => { + const ENV_KEYS = [ + 'GITNEXUS_EMBEDDING_URL', + 'GITNEXUS_EMBEDDING_MODEL', + 'GITNEXUS_EMBEDDING_DIMS', + ] as const; + const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + + const renderDoctor = async ( + vectorLoaded: boolean, + language: SupportedCliLanguage = 'en', + ): Promise => { + nativeProbeState.vectorLoaded = vectorLoaded; + setCliLanguage(language); + process.env.GITNEXUS_EMBEDDING_URL = 'http://127.0.0.1:9/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'synthetic-doctor'; + process.env.GITNEXUS_EMBEDDING_DIMS = '384'; + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await doctorCommand(); + + return log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + }; + + afterEach(() => { + nativeProbeState.vectorLoaded = true; + setCliLanguage(null); + vi.restoreAllMocks(); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + }); + + it('reports a loaded extension without claiming a repository index exists', async () => { + const output = await renderDoctor(true); + + expect(output).toContain('VECTOR extension: available'); + expect(output).toContain( + 'Semantic support: vector-index capable (repository index not checked)', + ); + expect(output).not.toContain('VECTOR index:'); + expect(output).not.toContain('Semantic mode:'); + }); + + it('reports exact-scan capability when the extension cannot load', async () => { + const output = await renderDoctor(false); + + expect(output).toContain('VECTOR extension: unavailable'); + expect(output).toContain('Semantic support: exact-scan only (VECTOR extension unavailable)'); + }); + + it('keeps the corrected capability claims localized', async () => { + const output = await renderDoctor(true, 'zh-CN'); + + expect(output).toContain('VECTOR 扩展:'); + expect(output).toContain('语义支持:'); + expect(output).toContain('支持向量索引(未检查仓库索引)'); + }); +}); + describe('doctor embedding-runtime support status', () => { it('flags local embeddings as unavailable on macOS Intel (darwin/x64)', () => { const { status, detail } = localEmbeddingDoctorStatus({