gitnexus 1.6.12-rc.26 → 1.6.12-rc.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ /** Add missing embeddings directly to a healthy index, checkpointing periodically. */
2
+ export declare const embeddingsSyncCommand: (inputPath?: string) => Promise<void>;
@@ -0,0 +1,127 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { cliInfo } from './cli-message.js';
4
+ import { getGitRoot } from '../storage/git.js';
5
+ import { acquireIndexLock } from '../storage/index-lock.js';
6
+ import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js';
7
+ import { closeLbug, executeQuery, executeWithReusedStatement, fetchExistingEmbeddingHashes, initLbug, } from '../core/lbug/lbug-adapter.js';
8
+ import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
9
+ import { resolveEmbeddingIdentity } from '../core/embeddings/embedding-identity.js';
10
+ import { checkpointKind, decideEmbeddingResume, mintInterruptedCheckpoint, mintPartialCheckpoint, mintUnverifiedCountCheckpoint, } from '../core/embedding-checkpoint.js';
11
+ import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } from '../core/embedding-count.js';
12
+ /** Add missing embeddings directly to a healthy index, checkpointing periodically. */
13
+ export const embeddingsSyncCommand = async (inputPath) => {
14
+ const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
15
+ if (!repoPath)
16
+ throw new Error('Not inside a git repository. Pass a repository path.');
17
+ const { lbugPath, metaPath } = getStoragePaths(repoPath);
18
+ const metaDir = path.dirname(metaPath);
19
+ const lock = await acquireIndexLock(metaDir);
20
+ try {
21
+ const meta = await loadMeta(metaDir);
22
+ if (!meta)
23
+ throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`);
24
+ if (meta.incrementalInProgress) {
25
+ throw new Error('The structural index is incomplete. Run gitnexus analyze --force first.');
26
+ }
27
+ let lbugStat;
28
+ try {
29
+ lbugStat = await lstat(lbugPath);
30
+ }
31
+ catch {
32
+ throw new Error(`The LadybugDB graph store at ${lbugPath} is missing. Run gitnexus analyze first.`);
33
+ }
34
+ if (!lbugStat.isFile()) {
35
+ throw new Error(`The LadybugDB graph store at ${lbugPath} is not a usable database file. Run gitnexus analyze first.`);
36
+ }
37
+ const identity = resolveEmbeddingIdentity();
38
+ let forceReembedNodeIds;
39
+ let resumedFrom;
40
+ if (meta.embeddingCheckpoint) {
41
+ const checkpoint = meta.embeddingCheckpoint;
42
+ const decision = decideEmbeddingResume(checkpoint, identity);
43
+ if (decision.action === 'abort')
44
+ throw new Error(decision.error);
45
+ const identityDiffers = checkpoint.provider !== identity.provider ||
46
+ checkpoint.model !== identity.model ||
47
+ checkpoint.dimensions !== identity.dimensions;
48
+ // `abandon` on a non-interrupted foreign identity drops the pending set
49
+ // only. Existing rows stay; sync would then embed the holes under the new
50
+ // identity and mix vector spaces. Fail closed — rebuild via analyze.
51
+ if (identityDiffers && checkpointKind(checkpoint) !== 'unverified-count') {
52
+ throw new Error(`Cannot sync embeddings: the index checkpoint was written by ${checkpoint.model} ` +
53
+ `(${checkpoint.provider}) at ${checkpoint.dimensions} dimensions, but this run ` +
54
+ `resolves ${identity.model} (${identity.provider}) at ${identity.dimensions}. ` +
55
+ 'Run `gitnexus analyze --embeddings --force` to rebuild under the new identity.');
56
+ }
57
+ cliInfo(decision.log);
58
+ if (decision.action === 'resume') {
59
+ forceReembedNodeIds = decision.pendingNodeIds;
60
+ resumedFrom = decision.resumedFrom;
61
+ }
62
+ }
63
+ await initLbug(lbugPath);
64
+ try {
65
+ const existing = await fetchExistingEmbeddingHashes(executeQuery);
66
+ let lastPercent = -1;
67
+ const countEmbeddings = async () => persistedEmbeddingCountOrUndefined(await measurePersistedEmbeddingCount(executeQuery));
68
+ const saveCheckpoint = async (checkpoint, pendingNodeIds, embeddings) => {
69
+ const latest = (await loadMeta(metaDir)) ?? meta;
70
+ await saveMeta(metaDir, {
71
+ ...latest,
72
+ ...(embeddings === undefined ? {} : { stats: { ...latest.stats, embeddings } }),
73
+ embeddingCheckpoint: mintInterruptedCheckpoint(identity, checkpoint, pendingNodeIds),
74
+ });
75
+ };
76
+ cliInfo(`Embedding ${repoPath}`);
77
+ cliInfo(`Checkpointed nodes already present: ${existing?.size ?? 0}`);
78
+ const result = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (progress) => {
79
+ const percent = Math.floor(progress.percent);
80
+ if (percent !== lastPercent && (percent % 5 === 0 || percent === 100)) {
81
+ lastPercent = percent;
82
+ cliInfo(` ${percent}% — ${progress.nodesProcessed ?? 0}/${progress.totalNodes ?? '?'} nodes`);
83
+ }
84
+ }, {}, undefined, existing && existing.size ? existing : undefined, {
85
+ forceReembedNodeIds,
86
+ onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => {
87
+ await saveCheckpoint(checkpoint, nodeIds);
88
+ },
89
+ onCheckpoint: async (checkpoint) => {
90
+ await saveCheckpoint(checkpoint, [], await countEmbeddings());
91
+ },
92
+ });
93
+ const embeddings = await countEmbeddings();
94
+ const latest = (await loadMeta(metaDir)) ?? meta;
95
+ if (embeddings === undefined) {
96
+ // Keep last-known stats.embeddings. An interrupted window marker would
97
+ // fail the identity gate on the next run even though this run finished;
98
+ // unverified-count is the recovery kind that forces a recount (#2790).
99
+ await saveMeta(metaDir, {
100
+ ...latest,
101
+ embeddingCheckpoint: result.failedNodeIds.length
102
+ ? mintPartialCheckpoint(identity, result, resumedFrom)
103
+ : mintUnverifiedCountCheckpoint(identity, {
104
+ nodesProcessed: result.nodesProcessed,
105
+ totalNodes: result.nodesProcessed,
106
+ chunksProcessed: result.chunksProcessed,
107
+ }),
108
+ });
109
+ throw new Error('Could not verify persisted embedding count.');
110
+ }
111
+ await saveMeta(metaDir, {
112
+ ...latest,
113
+ stats: { ...latest.stats, embeddings },
114
+ embeddingCheckpoint: result.failedNodeIds.length
115
+ ? mintPartialCheckpoint(identity, result, resumedFrom)
116
+ : undefined,
117
+ });
118
+ cliInfo(`Embeddings ready: ${embeddings}`);
119
+ }
120
+ finally {
121
+ await closeLbug().catch(() => { });
122
+ }
123
+ }
124
+ finally {
125
+ lock.release();
126
+ }
127
+ };
@@ -140,6 +140,7 @@ export declare const en: {
140
140
  readonly 'help.watch.details': "\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
141
141
  readonly 'error.watch.ambiguous': "`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
142
142
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
143
+ readonly 'help.command.embeddings.sync.description': "Add missing embeddings to an existing index, checkpointing periodically for safe resume";
143
144
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
144
145
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
145
146
  readonly 'help.command.mcp.description': "Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).";
@@ -277,5 +278,5 @@ export declare const en: {
277
278
  readonly 'help.option.group.contracts.repo': "Filter by repo";
278
279
  readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
279
280
  readonly 'help.identityCache.environment': "\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.";
280
- readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
281
+ readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
281
282
  };
@@ -142,6 +142,7 @@ export const en = {
142
142
  'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
143
143
  'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
144
144
  'help.command.analyze.description': 'Index a repository (full analysis)',
145
+ 'help.command.embeddings.sync.description': 'Add missing embeddings to an existing index, checkpointing periodically for safe resume',
145
146
  'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
146
147
  'help.command.serve.description': 'Start local HTTP server for web UI connection',
147
148
  'help.command.mcp.description': 'Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).',
@@ -279,5 +280,5 @@ export const en = {
279
280
  'help.option.group.contracts.repo': 'Filter by repo',
280
281
  'help.option.group.contracts.unmatched': 'Show only unmatched contracts',
281
282
  'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
282
- 'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
283
+ 'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
283
284
  };
@@ -141,6 +141,7 @@ export declare const cliResources: {
141
141
  readonly 'help.watch.details': "\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
142
142
  readonly 'error.watch.ambiguous': "`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
143
143
  readonly 'help.command.analyze.description': "Index a repository (full analysis)";
144
+ readonly 'help.command.embeddings.sync.description': "Add missing embeddings to an existing index, checkpointing periodically for safe resume";
144
145
  readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
145
146
  readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
146
147
  readonly 'help.command.mcp.description': "Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).";
@@ -278,7 +279,7 @@ export declare const cliResources: {
278
279
  readonly 'help.option.group.contracts.repo': "Filter by repo";
279
280
  readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
280
281
  readonly 'help.identityCache.environment': "\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.";
281
- readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
282
+ readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
282
283
  };
283
284
  readonly 'zh-CN': {
284
285
  'common.notIndexed': string;
@@ -422,6 +423,7 @@ export declare const cliResources: {
422
423
  'help.watch.details': string;
423
424
  'error.watch.ambiguous': string;
424
425
  'help.command.analyze.description': string;
426
+ 'help.command.embeddings.sync.description': string;
425
427
  'help.command.index.description': string;
426
428
  'help.command.serve.description': string;
427
429
  'help.command.mcp.description': string;
@@ -140,6 +140,7 @@ export declare const zhCN: {
140
140
  'help.watch.details': string;
141
141
  'error.watch.ambiguous': string;
142
142
  'help.command.analyze.description': string;
143
+ 'help.command.embeddings.sync.description': string;
143
144
  'help.command.index.description': string;
144
145
  'help.command.serve.description': string;
145
146
  'help.command.mcp.description': string;
@@ -140,6 +140,7 @@ export const zhCN = {
140
140
  'help.watch.details': '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
141
141
  'error.watch.ambiguous': '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
142
142
  'help.command.analyze.description': '索引仓库(完整分析)',
143
+ 'help.command.embeddings.sync.description': '向现有索引添加缺失的嵌入,并定期保存检查点以安全续跑',
143
144
  'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
144
145
  'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
145
146
  'help.command.mcp.description': '启动 MCP 服务器。默认为 stdio。使用 --http 启动远程 HTTP 服务器(Streamable HTTP: POST /mcp + 遗留 SSE: GET /sse, POST /messages)。',
@@ -277,5 +278,5 @@ export const zhCN = {
277
278
  'help.option.group.contracts.repo': '按仓库过滤',
278
279
  'help.option.group.contracts.unmatched': '仅显示未匹配契约',
279
280
  'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。',
280
- 'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
281
+ 'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
281
282
  };
package/dist/cli/index.js CHANGED
@@ -184,17 +184,21 @@ program
184
184
  .command('update')
185
185
  .description('Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).')
186
186
  .action(createLazyAction(() => import('./update.js'), 'updateCommand'));
187
- program
187
+ const embeddings = program
188
188
  .command('embeddings')
189
- .description('Manage the on-demand local embedding runtime')
189
+ .description(t('help.command.embeddings.description'));
190
+ embeddings
190
191
  .command('install')
191
- .description('Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. ' +
192
- 'Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). ' +
193
- 'Downloads only from your configured npm registry — mirrors and proxies apply.')
192
+ .description(t('help.command.embeddings.install.description'))
194
193
  .option('--cuda', "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; " +
195
194
  'set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)')
196
195
  .option('--force', 'Install into the runtime prefix even when the stack already resolves')
197
196
  .action(createLazyAction(() => import('./embeddings.js'), 'embeddingsInstallCommand'));
197
+ embeddings
198
+ .command('sync [path]')
199
+ .description(t('help.command.embeddings.sync.description'))
200
+ .addHelpText('after', () => t('help.analyze.environment'))
201
+ .action(createLbugLazyAction(() => import('./embeddings-sync.js'), 'embeddingsSyncCommand'));
198
202
  program
199
203
  .command('clean')
200
204
  .description('Delete GitNexus index for current repo')
@@ -157,6 +157,7 @@ const readConfig = () => {
157
157
  retryCapMs: parsePositiveIntegerEnv('GITNEXUS_EMBEDDING_RETRY_CAP_MS', HTTP_RETRY_CAP_MS, 300_000),
158
158
  minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
159
159
  timeoutMs: parsePositiveIntegerEnv(HTTP_TIMEOUT_ENV, DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS),
160
+ retryTimeouts: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_RETRY_TIMEOUTS', 0, 1) === 1,
160
161
  requestDimensions,
161
162
  };
162
163
  };
@@ -277,6 +278,23 @@ class RetryableEmbeddingBodyError extends Error {
277
278
  this.name = 'RetryableEmbeddingBodyError';
278
279
  }
279
280
  }
281
+ class RetryableEmbeddingTimeoutError extends Error {
282
+ timeoutMs;
283
+ constructor(timeoutMs, options) {
284
+ super(`Embedding request timed out after ${timeoutMs}ms`, options?.cause !== undefined ? { cause: options.cause } : undefined);
285
+ this.timeoutMs = timeoutMs;
286
+ this.name = 'RetryableEmbeddingTimeoutError';
287
+ }
288
+ }
289
+ /** Re-wrap an opt-in TimeoutError so `resilientFetch` retries it. Abort stays terminal. */
290
+ const throwIfRetryableTimeout = (err, retryTimeouts, callerAborted, timeoutMs) => {
291
+ if (retryTimeouts &&
292
+ !callerAborted &&
293
+ isTerminalNetworkError(err) &&
294
+ err.name === 'TimeoutError') {
295
+ throw new RetryableEmbeddingTimeoutError(timeoutMs, { cause: err });
296
+ }
297
+ };
280
298
  /**
281
299
  * Build the message for a 2xx body carrying the wrong number of vectors.
282
300
  *
@@ -304,7 +322,7 @@ const countMismatchMessage = (received, expected, safeEndpoint, batchIndex) => `
304
322
  * `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping
305
323
  * `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size.
306
324
  */
307
- const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensions, requestOptions = {}, maxAttempts = HTTP_MAX_RETRIES + 1, retryCapMs = HTTP_RETRY_CAP_MS, minIntervalMs = 0, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS) => {
325
+ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensions, requestOptions = {}, maxAttempts = HTTP_MAX_RETRIES + 1, retryCapMs = HTTP_RETRY_CAP_MS, minIntervalMs = 0, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, retryTimeouts = false) => {
308
326
  const requestBody = {
309
327
  input: batch,
310
328
  model,
@@ -341,7 +359,14 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
341
359
  const signal = requestOptions.signal
342
360
  ? AbortSignal.any([requestOptions.signal, timeoutSignal])
343
361
  : timeoutSignal;
344
- const attemptResp = await globalThis.fetch(input, { ...init, signal });
362
+ let attemptResp;
363
+ try {
364
+ attemptResp = await globalThis.fetch(input, { ...init, signal });
365
+ }
366
+ catch (err) {
367
+ throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
368
+ throw err;
369
+ }
345
370
  // Non-OK bodies are none of our business: hand the response straight
346
371
  // back so `resilientFetch` keeps classifying 4xx/5xx/429 unchanged.
347
372
  if (!attemptResp.ok)
@@ -361,15 +386,19 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
361
386
  // Not every `.json()` rejection is a parse error: the per-attempt
362
387
  // signal (`AbortSignal.any([caller, AbortSignal.timeout(...)])`) is
363
388
  // wired to the body stream, so a stalled body rejects with the abort
364
- // reason. Re-raise those untouched — `isTerminalNetworkError` is
365
- // `resilientFetch`'s own predicate, so this test agrees with
366
- // `classifyOutcome` by construction. Wrapping one would flip its
367
- // verdict from `terminal-network` (returned without retry AND
368
- // without touching the breaker, via `recordNeutral()`) to
369
- // `retryable-network` (retried, then `breaker.recordFailure()`): the
370
- // same timeout would take 3 attempts instead of 1, count toward the
371
- // process-global `embeddings-http` breaker, and reach the operator as
372
- // "unparseable response" so they never reach for the timeout knob.
389
+ // reason. Re-raise AbortError (and TimeoutError when retry is off)
390
+ // untouched — `isTerminalNetworkError` is `resilientFetch`'s own
391
+ // predicate, so this test agrees with `classifyOutcome` by
392
+ // construction. Wrapping one would flip its verdict from
393
+ // `terminal-network` (returned without retry AND without touching
394
+ // the breaker, via `recordNeutral()`) to `retryable-network`
395
+ // (retried, then `breaker.recordFailure()`): the same timeout would
396
+ // take 3 attempts instead of 1, count toward the process-global
397
+ // `embeddings-http` breaker, and reach the operator as "unparseable
398
+ // response" so they never reach for the timeout knob.
399
+ // Opt-in `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1` is the exception:
400
+ // TimeoutError is re-wrapped so the existing retry loop can retry it.
401
+ throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
373
402
  if (isTerminalNetworkError(err))
374
403
  throw err;
375
404
  throw new RetryableEmbeddingBodyError(unparseableMessage(), { cause: err });
@@ -411,6 +440,9 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
411
440
  if (err instanceof RetryableEmbeddingBodyError) {
412
441
  throw new HttpEmbeddingError(err.terminalMessage, { cause: err.cause });
413
442
  }
443
+ if (err instanceof RetryableEmbeddingTimeoutError) {
444
+ throw new HttpEmbeddingError(`${err.message} after ${maxAttempts} attempt(s) (${safeUrl(url)}, batch ${batchIndex})`, { cause: err.cause });
445
+ }
414
446
  if (err instanceof CircuitOpenError) {
415
447
  throw new HttpEmbeddingError(`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`, { cause: err });
416
448
  }
@@ -455,7 +487,7 @@ export const httpEmbed = async (texts, requestOptions = {}) => {
455
487
  const url = `${config.baseUrl}/embeddings`;
456
488
  const allVectors = [];
457
489
  for (const [batchIndex, batch] of chunk(texts, HTTP_BATCH_SIZE).entries()) {
458
- const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs);
490
+ const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs, config.retryTimeouts);
459
491
  // Defensive backstop, deliberately kept: `httpEmbedBatch` now rejects a
460
492
  // short body from *inside* the retry loop (through the same
461
493
  // `countMismatchMessage`), so in practice this branch is unreachable. It
@@ -506,7 +538,7 @@ export const httpEmbedQuery = async (text, requestOptions = {}) => {
506
538
  if (!config)
507
539
  throw new Error('HTTP embedding not configured');
508
540
  const url = `${config.baseUrl}/embeddings`;
509
- const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs);
541
+ const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs, config.retryTimeouts);
510
542
  // Defensive backstop like the `httpEmbed` one above: an empty `data` array is
511
543
  // now a cardinality mismatch (0 vectors for 1 text) rejected and retried
512
544
  // inside `httpEmbedBatch`, so this branch is unreachable in practice.
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Ruby import resolution rules:
5
5
  * - `require_relative './foo'` → resolve relative to the importing file's dir
6
- * - `require 'foo'` → suffix-match via the existing Ruby import resolver
7
- * - External gems → null (unresolvable within the repo)
6
+ * - `require 'foo'` → use scoped gem metadata before legacy suffix matching
7
+ * - Known local gems → resolve only within their declared load roots
8
+ * - Known external gems → null, even if an unrelated repo file suffix matches
8
9
  */
9
10
  export interface RubyResolveContext {
10
11
  readonly fromFile: string;
@@ -18,9 +19,10 @@ export interface RubyResolveContext {
18
19
  * against the importing file's directory, trying `.rb` and `/index.rb`
19
20
  * suffixes.
20
21
  *
21
- * For bare requires (gem-style like `'json'`, `'serializable'`), delegates
22
- * to the existing `resolveRubyImportInternal` which uses suffix matching.
23
- *
24
- * Returns `null` for external gems that have no matching file in the repo.
22
+ * For bare requires, scoped manifest metadata takes precedence: known local
23
+ * gems resolve only within their declared load roots (a miss returns `null`),
24
+ * and known external gem prefixes return `null` even if a repo suffix matches.
25
+ * Only requires without matching gem evidence delegate to the existing
26
+ * `resolveRubyImportInternal` suffix matcher.
25
27
  */
26
- export declare function resolveRubyImportTarget(targetRaw: string, fromFile: string, allFilePaths: ReadonlySet<string>, _resolutionConfig?: unknown): string | readonly string[] | null;
28
+ export declare function resolveRubyImportTarget(targetRaw: string, fromFile: string, allFilePaths: ReadonlySet<string>, resolutionConfig?: unknown): string | readonly string[] | null;
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Ruby import resolution rules:
5
5
  * - `require_relative './foo'` → resolve relative to the importing file's dir
6
- * - `require 'foo'` → suffix-match via the existing Ruby import resolver
7
- * - External gems → null (unresolvable within the repo)
6
+ * - `require 'foo'` → use scoped gem metadata before legacy suffix matching
7
+ * - Known local gems → resolve only within their declared load roots
8
+ * - Known external gems → null, even if an unrelated repo file suffix matches
8
9
  */
9
10
  import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
10
11
  import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
11
12
  import { isHeritageMarker } from '../../utils/heritage-marker.js';
13
+ import { findRubyResolutionScope, } from './resolution-config.js';
12
14
  // ─── resolveRubyImportTarget ──────────────────────────────────────────────
13
15
  /**
14
16
  * ScopeResolver-shaped adapter:
@@ -18,12 +20,13 @@ import { isHeritageMarker } from '../../utils/heritage-marker.js';
18
20
  * against the importing file's directory, trying `.rb` and `/index.rb`
19
21
  * suffixes.
20
22
  *
21
- * For bare requires (gem-style like `'json'`, `'serializable'`), delegates
22
- * to the existing `resolveRubyImportInternal` which uses suffix matching.
23
- *
24
- * Returns `null` for external gems that have no matching file in the repo.
23
+ * For bare requires, scoped manifest metadata takes precedence: known local
24
+ * gems resolve only within their declared load roots (a miss returns `null`),
25
+ * and known external gem prefixes return `null` even if a repo suffix matches.
26
+ * Only requires without matching gem evidence delegate to the existing
27
+ * `resolveRubyImportInternal` suffix matcher.
25
28
  */
26
- export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, _resolutionConfig) {
29
+ export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig) {
27
30
  if (!targetRaw)
28
31
  return null;
29
32
  if (isHeritageMarker(targetRaw))
@@ -37,7 +40,16 @@ export function resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, _reso
37
40
  const resolved = resolveRelative(targetRaw, fromDir, allFilePaths);
38
41
  return resolved;
39
42
  }
40
- // ── require: bare/gem-style suffix matching ─────────────────────────
43
+ // ── require: scoped gem evidence before repository-wide fallback ────
44
+ const config = resolutionConfig;
45
+ const scope = config === null || config === undefined ? undefined : findRubyResolutionScope(config, fromFile);
46
+ if (scope !== undefined) {
47
+ const localGemTarget = resolveLocalGemTarget(targetRaw, scope, allFilePaths);
48
+ if (localGemTarget !== undefined)
49
+ return localGemTarget;
50
+ if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes))
51
+ return null;
52
+ }
41
53
  return resolveBare(targetRaw, allFilePaths);
42
54
  }
43
55
  // ─── internal helpers ─────────────────────────────────────────────────────
@@ -73,6 +85,55 @@ function resolveRelative(targetRaw, fromDir, allFilePaths) {
73
85
  return resolvedPath;
74
86
  return null;
75
87
  }
88
+ /**
89
+ * Yield the require stem, then each slash-delimited ancestor. This makes both
90
+ * local-root and external-gem lookup O(require path depth), not O(gem count).
91
+ * A trailing `.rb` is stripped first so `require 'my_engine.rb'` matches the
92
+ * configured prefix `my_engine`, matching Ruby's optional-suffix require.
93
+ */
94
+ function requirePrefixCandidates(targetRaw) {
95
+ const stem = targetRaw.endsWith('.rb') ? targetRaw.slice(0, -3) : targetRaw;
96
+ if (stem.length === 0)
97
+ return [];
98
+ const candidates = [stem];
99
+ let slash = stem.lastIndexOf('/');
100
+ while (slash !== -1) {
101
+ candidates.push(stem.slice(0, slash));
102
+ slash = stem.lastIndexOf('/', slash - 1);
103
+ }
104
+ return candidates;
105
+ }
106
+ function matchesRequirePrefix(targetRaw, prefixes) {
107
+ return requirePrefixCandidates(targetRaw).some((candidate) => prefixes.has(candidate));
108
+ }
109
+ /**
110
+ * Resolve a path/gemspec-backed gem against its declared Ruby load roots.
111
+ * `undefined` means no local-gem prefix matched; `null` means one matched but
112
+ * none of its declared load roots contained the target.
113
+ */
114
+ function resolveLocalGemTarget(targetRaw, scope, allFilePaths) {
115
+ for (const prefix of requirePrefixCandidates(targetRaw)) {
116
+ const loadRoots = scope.localLoadRootsByPrefix.get(prefix);
117
+ if (loadRoots === undefined)
118
+ continue;
119
+ for (const loadRoot of loadRoots) {
120
+ const base = loadRoot ? `${loadRoot}/${targetRaw}` : targetRaw;
121
+ if (base.endsWith('.rb')) {
122
+ if (allFilePaths.has(base))
123
+ return base;
124
+ continue;
125
+ }
126
+ const rbFile = `${base}.rb`;
127
+ if (allFilePaths.has(rbFile))
128
+ return rbFile;
129
+ }
130
+ // The prefix is owned by a known local gem. If its declared roots do not
131
+ // contain the target, repository-wide suffix matching would fabricate an
132
+ // edge to an unrelated file.
133
+ return null;
134
+ }
135
+ return undefined;
136
+ }
76
137
  /**
77
138
  * Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
78
139
  * via suffix matching using the existing Ruby import resolver.
@@ -0,0 +1,28 @@
1
+ export interface RubyResolutionScope {
2
+ /** Require prefixes provided by gems whose source is outside this repository. */
3
+ readonly externalRequirePrefixes: ReadonlySet<string>;
4
+ /** Require prefix -> repository-relative Ruby load roots for local gems. */
5
+ readonly localLoadRootsByPrefix: ReadonlyMap<string, readonly string[]>;
6
+ }
7
+ export interface RubyResolutionConfig {
8
+ /** Manifest directory (repository-relative POSIX path) -> dependency scope. */
9
+ readonly scopesByDirectory: ReadonlyMap<string, RubyResolutionScope>;
10
+ }
11
+ /**
12
+ * Select the nearest manifest directory that owns `fromFile`.
13
+ *
14
+ * Walking ancestors makes lookup O(path depth), independent of how many
15
+ * sibling Gemfiles a monorepo contains.
16
+ */
17
+ export declare function findRubyResolutionScope(config: RubyResolutionConfig, fromFile: string): RubyResolutionScope | undefined;
18
+ /**
19
+ * Statically collect Ruby dependency sources without evaluating Gemfile or
20
+ * gemspec code. Scopes stay separate by manifest directory so sibling projects
21
+ * in a monorepo cannot suppress one another's local imports. Lockfiles
22
+ * contribute remote GEM/GIT specs and local PATH/GEMSPEC load roots only when
23
+ * an adjacent Gemfile or gemspec establishes a Bundler/RubyGems project.
24
+ *
25
+ * No declarative manifest means no safe gate, so return null and preserve the
26
+ * resolver's existing fail-open behavior for loose script directories.
27
+ */
28
+ export declare function loadRubyResolutionConfig(repoPath: string): RubyResolutionConfig | null;