gitnexus 1.6.11-rc.11 → 1.6.11-rc.13

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.
@@ -187,7 +187,7 @@ export declare const en: {
187
187
  readonly 'help.option.serve.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)";
188
188
  readonly 'help.option.mcp.http': "Serve MCP over HTTP instead of stdio (for remote clients)";
189
189
  readonly 'help.option.mcp.host': "HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.";
190
- readonly 'help.option.mcp.authToken': "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.";
190
+ readonly 'help.option.mcp.authToken': "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.";
191
191
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
192
192
  readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
193
193
  readonly 'help.option.clean.all': "Clean all indexed repos";
@@ -189,7 +189,7 @@ export const en = {
189
189
  'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
190
190
  'help.option.mcp.http': 'Serve MCP over HTTP instead of stdio (for remote clients)',
191
191
  'help.option.mcp.host': 'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.',
192
- 'help.option.mcp.authToken': 'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.',
192
+ 'help.option.mcp.authToken': "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.",
193
193
  'help.option.force.confirmation': 'Skip confirmation prompt',
194
194
  'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
195
195
  'help.option.clean.all': 'Clean all indexed repos',
@@ -188,7 +188,7 @@ export declare const cliResources: {
188
188
  readonly 'help.option.serve.host': "Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)";
189
189
  readonly 'help.option.mcp.http': "Serve MCP over HTTP instead of stdio (for remote clients)";
190
190
  readonly 'help.option.mcp.host': "HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.";
191
- readonly 'help.option.mcp.authToken': "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.";
191
+ readonly 'help.option.mcp.authToken': "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.";
192
192
  readonly 'help.option.force.confirmation': "Skip confirmation prompt";
193
193
  readonly 'help.option.uninstall.force': "Apply the changes (default is a dry-run preview)";
194
194
  readonly 'help.option.clean.all': "Clean all indexed repos";
@@ -187,7 +187,7 @@ export const zhCN = {
187
187
  'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
188
188
  'help.option.mcp.http': '使用 HTTP 代替 stdio 提供 MCP 服务(适合远程客户端)',
189
189
  'help.option.mcp.host': 'HTTP 绑定地址(仅与 --http 搭配使用)。默认:127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。',
190
- 'help.option.mcp.authToken': '要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
190
+ 'help.option.mcp.authToken': '要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置,该变量同时为 gitnexus serve 的 /api/mcp 路由启用 MCP Bearer 认证。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。',
191
191
  'help.option.force.confirmation': '跳过确认提示',
192
192
  'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
193
193
  'help.option.clean.all': '清理所有已索引仓库',
package/dist/cli/index.js CHANGED
@@ -149,7 +149,7 @@ program
149
149
  .option('--http', 'Serve MCP over HTTP instead of stdio (for remote clients)')
150
150
  .option('-p, --port <port>', 'HTTP port (only with --http). Default: 3000', '3000')
151
151
  .option('--host <host>', 'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.', '127.0.0.1')
152
- .option('--auth-token <token>', 'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.')
152
+ .option('--auth-token <token>', "Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var, which also enables MCP Bearer auth on gitnexus serve's /api/mcp route. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.")
153
153
  .action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
154
154
  program
155
155
  .command('list')
@@ -2,7 +2,7 @@
2
2
  * Parse implementation — chunked parse + resolve loop.
3
3
  *
4
4
  * This is the core parsing engine of the ingestion pipeline. It reads
5
- * source files in byte-budget chunks (~20MB each), parses via the worker
5
+ * source files in stable hash-bucket packs (~2MB each by default), parses via the worker
6
6
  * pool (the sole parse path — there is no sequential fallback), and emits
7
7
  * route CALLS edges. Import,
8
8
  * call, and inheritance resolution are owned by the scope-resolution
@@ -2,7 +2,7 @@
2
2
  * Parse implementation — chunked parse + resolve loop.
3
3
  *
4
4
  * This is the core parsing engine of the ingestion pipeline. It reads
5
- * source files in byte-budget chunks (~20MB each), parses via the worker
5
+ * source files in stable hash-bucket packs (~2MB each by default), parses via the worker
6
6
  * pool (the sole parse path — there is no sequential fallback), and emits
7
7
  * route CALLS edges. Import,
8
8
  * call, and inheritance resolution are owned by the scope-resolution
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { BindingAccumulator, enrichExportedTypeMap, } from '../binding-accumulator.js';
18
18
  import { mergeChunkResults, dispatchChunkParse } from '../parsing-processor.js';
19
- import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, } from '../../../storage/parse-cache.js';
19
+ import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, packParseCacheChunks, } from '../../../storage/parse-cache.js';
20
20
  import { clearParsedFileStore, persistParsedFileChunk, loadParsedFilesForPaths, getDurableParsedFileDir, loadDurableParsedFileIndex, prepareDurableParsedFileChunk, restoreDurableParsedFileShard, } from '../../../storage/parsedfile-store.js';
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
22
22
  import { processRoutesFromExtracted, resolveRouteHandlerSymbols, buildExportedTypeMapFromGraph, } from '../call-processor.js';
@@ -111,38 +111,18 @@ export function heapPressureRemedy(heapLimitBytes) {
111
111
  return (`This machine is at its memory ceiling: exclude generated or vendored directories ` +
112
112
  `via .gitnexusignore, or analyze on a machine with more memory.`);
113
113
  }
114
- /** Max bytes of source content to load per parse chunk.
114
+ /** Max bytes of source content to load per parse cache pack.
115
115
  *
116
- * Memory bound for the worker pool dispatch + a granularity knob for
117
- * the parse cache. A single file change invalidates only its enclosing
118
- * chunk, so smaller budgets → finer-grained invalidation.
119
- *
120
- * Override via GITNEXUS_CHUNK_BYTE_BUDGET (bytes) — the default of 2MB
121
- * gives a useful invalidation floor (~1/N chunks on a multi-MB repo)
122
- * while keeping worker dispatch overhead under 5% on cold runs.
123
- */
124
- /**
125
- * Built-in chunk byte budget when neither `PipelineOptions.chunkByteBudget`
126
- * nor `GITNEXUS_CHUNK_BYTE_BUDGET` is set. Tuned to give a useful
127
- * cache-invalidation floor (~1/N chunks on a multi-MB repo) while keeping
128
- * worker dispatch overhead under 5% on cold runs. Resolution happens at
129
- * call time inside `runChunkedParseAndResolve` (U14 from PR #1693 review)
130
- * — previously this was a module-load IIFE, which froze the env value at
131
- * import time and meant per-call option threading silently no-op'd.
116
+ * Granularity knob for the parse cache: a single file change invalidates only
117
+ * its enclosing pack. Override via GITNEXUS_CHUNK_BYTE_BUDGET. Resolution
118
+ * happens at call time (U14 from PR #1693) — not at module load.
132
119
  */
133
120
  const DEFAULT_CHUNK_BYTE_BUDGET = 2 * 1024 * 1024;
134
121
  /**
135
- * Per-worker share of a chunk's byte budget when auto-scaling (#worker-idle).
136
- *
137
- * A chunk is a single `WorkerPool.dispatch` unit; the pool fans a chunk's files
138
- * into sub-batch jobs and assigns them to idle workers (`wakeIdleSlots`). When
139
- * the chunk budget (2 MB) was far below the 8 MB sub-batch cap, every chunk
140
- * produced exactly ONE job → ONE busy worker while the other N-1 sat idle. To
141
- * keep all workers fed, the auto chunk budget now scales as
142
- * `poolSize × CHUNK_BYTES_PER_WORKER`, so each dispatch carries enough work to
143
- * fan across the whole pool. Sequential / explicit-budget runs are unaffected.
122
+ * Byte unit for auto pool sizing (one worker per this much source). Same
123
+ * magnitude as the default cache pack, but not a membership input (#3088).
144
124
  */
145
- const CHUNK_BYTES_PER_WORKER = 2 * 1024 * 1024;
125
+ const CHUNK_BYTES_PER_WORKER = DEFAULT_CHUNK_BYTE_BUDGET;
146
126
  /**
147
127
  * Target jobs-per-worker per dispatch. More jobs than workers gives the pool's
148
128
  * idle-slot assignment room to load-balance (a slow job doesn't strand a worker
@@ -151,16 +131,14 @@ const CHUNK_BYTES_PER_WORKER = 2 * 1024 * 1024;
151
131
  const TARGET_JOBS_PER_WORKER = 3;
152
132
  /** Floor for a derived sub-batch so jobs don't shrink to per-file IPC churn. */
153
133
  const MIN_SUB_BATCH_BYTES = 256 * 1024;
154
- function resolveChunkByteBudget(options, effectivePoolSize = 1) {
134
+ function resolveChunkByteBudget(options) {
155
135
  const opt = options?.chunkByteBudget;
156
136
  if (typeof opt === 'number' && Number.isFinite(opt) && opt > 0)
157
137
  return opt;
158
138
  const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET);
159
139
  if (Number.isFinite(env) && env > 0)
160
140
  return env;
161
- // Auto: size each chunk so a dispatch can fan across the whole pool. A
162
- // single-worker (tiny-repo) run keeps the original 2 MB invalidation floor.
163
- return Math.max(DEFAULT_CHUNK_BYTE_BUDGET, effectivePoolSize * CHUNK_BYTES_PER_WORKER);
141
+ return DEFAULT_CHUNK_BYTE_BUDGET;
164
142
  }
165
143
  /**
166
144
  * Whole-repo, cross-file route extraction (main thread).
@@ -367,14 +345,6 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
367
345
  }
368
346
  }
369
347
  const unavailableScopeLanguageFiles = [...skippedByLang.values()].reduce((total, count) => total + count, 0);
370
- // Sort parseableScanned alphabetically for stable chunk membership
371
- // across runs (Finding 4). Without this, filesystem-scan order can
372
- // shift between runs (notably on macOS APFS where directory entry
373
- // order can change after modifications) — different files in the
374
- // same chunk → different chunk hash → cache miss even when no file
375
- // content changed. The cache also becomes platform-specific: a
376
- // Linux-built cache misses on macOS for the same repo.
377
- parseableScanned.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
378
348
  const totalParseable = parseableScanned.length;
379
349
  const totalBytes = parseableScanned.reduce((sum, f) => sum + f.size, 0);
380
350
  if (totalParseable === 0) {
@@ -416,24 +386,24 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
416
386
  // runs. Resolving in the function body restores per-call configurability
417
387
  // and matches the pattern used by resolveAutoPoolSize and the U1
418
388
  // parseChunkConcurrency resolver.
419
- // Effective worker count, computed up-front so the chunk budget can scale to
420
- // keep the whole pool busy (#worker-idle). The pool is ALWAYS used (sequential
421
- // parsing was removed; the disabled channels threw above). Size it to the
422
- // work: an explicit `--workers <N>` pins the size; otherwise the cores-based
423
- // auto size is capped by the repo's worth of work (~one worker per
424
- // CHUNK_BYTES_PER_WORKER of source) so a tiny repo spawns ~1 worker instead of
425
- // a full pool, replacing the job the deleted small-repo threshold used to do.
426
- // KTD-3 of the remove-sequential plan; the cap formula is intentionally coarse
427
- // (tuning deferred).
389
+ // Effective worker count: explicit `--workers <N>` pins it; otherwise
390
+ // cores-based auto size is capped by source bytes / CHUNK_BYTES_PER_WORKER
391
+ // so a tiny repo does not spawn a full idle pool. Cache pack membership
392
+ // is independent of this number (#3088).
428
393
  const explicitPoolSize = options?.workerPoolSize;
429
394
  const workProportionalCap = Math.max(1, Math.ceil(totalBytes / CHUNK_BYTES_PER_WORKER));
430
395
  const effectivePoolSize = explicitPoolSize && explicitPoolSize > 0
431
396
  ? explicitPoolSize
432
397
  : Math.min(resolveAutoPoolSize(), workProportionalCap);
433
- const chunkByteBudget = resolveChunkByteBudget(options, effectivePoolSize);
434
- // Sub-batch size so each chunk fans into ~`TARGET_JOBS_PER_WORKER` jobs per
435
- // worker, giving the pool's idle-slot assignment room to load-balance. An
436
- // explicit `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` operator override wins.
398
+ // Cache packs: stable (language, hash(path) mod 128) buckets, then the
399
+ // per-call byte budget inside each bucket (#3088). Pool size is used only
400
+ // for worker count and sub-batch fan-out, not membership.
401
+ const chunkByteBudget = resolveChunkByteBudget(options);
402
+ // Sub-batch size so a 2 MiB pack fans into ~TARGET_JOBS_PER_WORKER jobs
403
+ // per worker, floored at MIN_SUB_BATCH_BYTES (256 KiB) so an 8-worker
404
+ // pool still gets ~8 jobs from one pack instead of one idle-heavy job
405
+ // (#worker-idle). Do not derive this from pool×2 MiB while dispatching a
406
+ // 2 MiB pack. An explicit GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES wins.
437
407
  const subBatchEnv = Number(process.env.GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES);
438
408
  const dispatchSubBatchMaxBytes = Number.isFinite(subBatchEnv) && subBatchEnv > 0
439
409
  ? subBatchEnv
@@ -451,20 +421,11 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
451
421
  logger.warn(`Large repository: analyzing ${parseableScanned.length} files needs roughly ${Math.round(projectedHeapNeedBytes / 1024 / 1024 / 1024)}GB of memory, ` +
452
422
  `but Node is limited to ${Math.round(heapLimitBytes / 1024 / 1024 / 1024)}GB — analyze may stop early. ${heapPressureRemedy(heapLimitBytes)}`);
453
423
  }
454
- const chunks = [];
455
- let currentChunk = [];
456
- let currentBytes = 0;
457
- for (const file of parseableScanned) {
458
- if (currentChunk.length > 0 && currentBytes + file.size > chunkByteBudget) {
459
- chunks.push(currentChunk);
460
- currentChunk = [];
461
- currentBytes = 0;
462
- }
463
- currentChunk.push(file.path);
464
- currentBytes += file.size;
465
- }
466
- if (currentChunk.length > 0)
467
- chunks.push(currentChunk);
424
+ const chunks = packParseCacheChunks(parseableScanned.map((file) => ({
425
+ path: file.path,
426
+ size: file.size,
427
+ language: getLanguageFromFilename(file.path) ?? 'unknown',
428
+ })), chunkByteBudget);
468
429
  const numChunks = chunks.length;
469
430
  if (isDev) {
470
431
  const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024);
@@ -20,7 +20,7 @@ import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
21
21
  import { ftsDegradedWarning } from '../core/search/fts-indexes.js';
22
22
  import { LocalBackend } from '../mcp/local/local-backend.js';
23
- import { mountMCPEndpoints } from './mcp-http.js';
23
+ import { installServeMcpAuth, mountMCPEndpoints } from './mcp-http.js';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { isTerminalJobStatus, JobManager } from './analyze-job.js';
26
26
  import { mountSSEProgress } from './sse-progress.js';
@@ -616,6 +616,9 @@ export const createServer = async (port, host = '127.0.0.1') => {
616
616
  callback(null, isAllowedOrigin(origin));
617
617
  },
618
618
  }));
619
+ // Optional protocol-layer auth for the MCP route. Keep this before the
620
+ // global body parser so rejected requests do not consume the JSON budget.
621
+ installServeMcpAuth(app);
619
622
  app.use(express.json({ limit: '10mb' }));
620
623
  // Origin guard for write routes: loopback, the server's own bound host, and
621
624
  // any configured public origin — prevents CSRF from other devices.
@@ -9,4 +9,12 @@
9
9
  */
10
10
  import type { Express } from 'express';
11
11
  import type { LocalBackend } from '../mcp/local/local-backend.js';
12
+ /**
13
+ * Protect serve's /api/mcp route when the shared MCP bearer token is configured.
14
+ *
15
+ * This middleware must be installed before Express's global JSON parser so an
16
+ * unauthenticated request body is rejected before it is parsed. The standalone
17
+ * `gitnexus mcp --http` server resolves the same environment variable.
18
+ */
19
+ export declare function installServeMcpAuth(app: Express, env?: NodeJS.ProcessEnv): boolean;
12
20
  export declare function mountMCPEndpoints(app: Express, backend: LocalBackend): Promise<() => Promise<void>>;
@@ -7,9 +7,24 @@
7
7
  *
8
8
  * Used by server/api.ts to wire up the full web server.
9
9
  */
10
- import { createStreamableHttpHandler } from '../mcp/http-transport.js';
10
+ import { createAuthMiddleware, createStreamableHttpHandler, resolveAuthToken, } from '../mcp/http-transport.js';
11
11
  import { createMcpRepositoryPolicy } from '../mcp/repository-policy.js';
12
12
  import { logger } from '../core/logger.js';
13
+ /**
14
+ * Protect serve's /api/mcp route when the shared MCP bearer token is configured.
15
+ *
16
+ * This middleware must be installed before Express's global JSON parser so an
17
+ * unauthenticated request body is rejected before it is parsed. The standalone
18
+ * `gitnexus mcp --http` server resolves the same environment variable.
19
+ */
20
+ export function installServeMcpAuth(app, env = process.env) {
21
+ const authToken = resolveAuthToken(undefined, env);
22
+ if (!authToken)
23
+ return false;
24
+ app.use('/api/mcp', createAuthMiddleware(authToken));
25
+ logger.info('Bearer authentication enabled for serve /api/mcp');
26
+ return true;
27
+ }
13
28
  export async function mountMCPEndpoints(app, backend) {
14
29
  const repositoryPolicy = await createMcpRepositoryPolicy(backend);
15
30
  const { handler, cleanup } = createStreamableHttpHandler(backend, { repositoryPolicy });
@@ -6,10 +6,12 @@
6
6
  * does is skip the tree-sitter worker dispatch when a chunk's contents
7
7
  * haven't changed since the last run.
8
8
  *
9
- * Granularity: chunk-level. The parse phase chunks files into ~20MB byte
10
- * budgets. The cache key is `sha256(joined(filePath:contentHash for each
11
- * file in the chunk, sorted))`. A change to a single file invalidates only
12
- * that file's chunk — typically 1 of ~50 chunks on a 1000-file repo.
9
+ * Granularity: chunk-level. Files are assigned to a stable
10
+ * `(language, hash(path) mod 128)` bucket, then packed to a 2 MiB (or
11
+ * operator) byte budget *inside* that bucket. Membership does not depend
12
+ * on worker count. The cache key is `sha256(joined(filePath:contentHash
13
+ * for each file in the chunk, sorted))`. A content edit invalidates only
14
+ * that file's pack; add/delete/rename only the affected bucket.
13
15
  *
14
16
  * Why not per-file:
15
17
  * - Workers process sub-batches and emit aggregated `ParseWorkerResult`s.
@@ -23,6 +25,22 @@
23
25
  */
24
26
  import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.js';
25
27
  export declare const PARSE_CACHE_VERSION: string;
28
+ /** Stable parse-cache bucket count (#3088). Changing this requires SCHEMA_BUMP. */
29
+ export declare const PARSE_CACHE_BUCKET_COUNT = 128;
30
+ /** Bucket id for cache membership: `sha256(path) mod N` without IEEE-754 truncation. */
31
+ export declare const parseCacheBucketId: (filePath: string) => number;
32
+ export type ParseCachePackFile = {
33
+ path: string;
34
+ size: number;
35
+ language: string;
36
+ };
37
+ /**
38
+ * Pack files into parse-cache chunks: group by (language, bucket id), sort
39
+ * paths inside the group, then cut at `byteBudget`. Bucket visit order is
40
+ * the lexicographic order of `${language}\\0${bucketId}` keys (deterministic,
41
+ * independent of scan order and worker count).
42
+ */
43
+ export declare const packParseCacheChunks: (files: readonly ParseCachePackFile[], byteBudget: number) => string[][];
26
44
  /** Runtime view: keyed Map for fast lookup; mutated in place during a run. */
27
45
  export interface ParseCache {
28
46
  version: string;
@@ -6,10 +6,12 @@
6
6
  * does is skip the tree-sitter worker dispatch when a chunk's contents
7
7
  * haven't changed since the last run.
8
8
  *
9
- * Granularity: chunk-level. The parse phase chunks files into ~20MB byte
10
- * budgets. The cache key is `sha256(joined(filePath:contentHash for each
11
- * file in the chunk, sorted))`. A change to a single file invalidates only
12
- * that file's chunk — typically 1 of ~50 chunks on a 1000-file repo.
9
+ * Granularity: chunk-level. Files are assigned to a stable
10
+ * `(language, hash(path) mod 128)` bucket, then packed to a 2 MiB (or
11
+ * operator) byte budget *inside* that bucket. Membership does not depend
12
+ * on worker count. The cache key is `sha256(joined(filePath:contentHash
13
+ * for each file in the chunk, sorted))`. A content edit invalidates only
14
+ * that file's pack; add/delete/rename only the affected bucket.
13
15
  *
14
16
  * Why not per-file:
15
17
  * - Workers process sub-batches and emit aggregated `ParseWorkerResult`s.
@@ -26,6 +28,7 @@ import { createRequire } from 'module';
26
28
  import fs from 'fs/promises';
27
29
  import path from 'path';
28
30
  import { fileURLToPath } from 'url';
31
+ import { compareCodeUnits } from '../lib/utils.js';
29
32
  /**
30
33
  * Cache version composed of:
31
34
  * - A schema bump knob (`SCHEMA_BUMP`) for hand-controlled invalidation
@@ -627,6 +630,16 @@ import { fileURLToPath } from 'url';
627
630
  // therefore takes 79, the next free value above origin/main and every open PR
628
631
  // found by the contents-API scan at their exact head SHAs.
629
632
  //
633
+ // 79 -> 80 for #3088: parse-cache membership is `(language, sha256(path) mod
634
+ // 128)` then the byte budget *inside* that bucket. Worker count is no longer a
635
+ // membership input, so a warm v79 cache keyed sequential scan-order packs (and
636
+ // on multi-worker hosts, pool×2 MiB mega-chunks) must miss. Sidecar-era
637
+ // ParsedFile stores (#3086/#3087) share PARSE_CACHE_VERSION, so both stores
638
+ // invalidate in lockstep. origin/main at allocation is 79; open PRs that still
639
+ // touch gitnexus/src/storage/parse-cache.ts claim 78 (#3060), 71 (#2840), and
640
+ // 2 (#1616) — none claim 80. RE-CHECK AGAINST origin/main AND OPEN PRs
641
+ // IMMEDIATELY BEFORE MERGING.
642
+ //
630
643
  // WHY THIS IS STILL A HAND-PICKED NUMBER, when `SCHEMA_FINGERPRINT` next door
631
644
  // is a derived sha256 that cannot collide. The derivation exists and already
632
645
  // runs: `resolveAnalyzerRunnerIdentity` computes `build.digest` over the
@@ -645,7 +658,7 @@ import { fileURLToPath } from 'url';
645
658
  // `route-extractors/` and `workers/` module content — would close the missing-
646
659
  // bump axis without invalidating on unrelated churn, and is the real follow-up.
647
660
  // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
648
- const SCHEMA_BUMP = 79;
661
+ const SCHEMA_BUMP = 80;
649
662
  const GITNEXUS_PKG_VERSION = (() => {
650
663
  try {
651
664
  // package.json sits at gitnexus/package.json — two levels up from
@@ -673,16 +686,56 @@ const GITNEXUS_PKG_VERSION = (() => {
673
686
  return '0.0.0-unknown';
674
687
  })();
675
688
  export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`;
689
+ /** SHA-256 hex of a string or buffer (paths for bucket ids, contents for cache keys). */
690
+ const sha256Hex = (input) => createHash('sha256')
691
+ .update(typeof input === 'string' ? Buffer.from(input) : input)
692
+ .digest('hex');
693
+ /** Stable parse-cache bucket count (#3088). Changing this requires SCHEMA_BUMP. */
694
+ export const PARSE_CACHE_BUCKET_COUNT = 128;
695
+ /** Bucket id for cache membership: `sha256(path) mod N` without IEEE-754 truncation. */
696
+ export const parseCacheBucketId = (filePath) => Number(BigInt(`0x${sha256Hex(filePath)}`) % BigInt(PARSE_CACHE_BUCKET_COUNT));
697
+ /**
698
+ * Pack files into parse-cache chunks: group by (language, bucket id), sort
699
+ * paths inside the group, then cut at `byteBudget`. Bucket visit order is
700
+ * the lexicographic order of `${language}\\0${bucketId}` keys (deterministic,
701
+ * independent of scan order and worker count).
702
+ */
703
+ export const packParseCacheChunks = (files, byteBudget) => {
704
+ const buckets = new Map();
705
+ for (const file of files) {
706
+ const key = `${file.language}\0${parseCacheBucketId(file.path)}`;
707
+ const list = buckets.get(key);
708
+ if (list)
709
+ list.push(file);
710
+ else
711
+ buckets.set(key, [file]);
712
+ }
713
+ const chunks = [];
714
+ for (const key of [...buckets.keys()].sort()) {
715
+ const group = buckets.get(key);
716
+ group.sort((a, b) => compareCodeUnits(a.path, b.path));
717
+ let current = [];
718
+ let bytes = 0;
719
+ for (const file of group) {
720
+ if (current.length > 0 && bytes + file.size > byteBudget) {
721
+ chunks.push(current);
722
+ current = [];
723
+ bytes = 0;
724
+ }
725
+ current.push(file.path);
726
+ bytes += file.size;
727
+ }
728
+ if (current.length > 0)
729
+ chunks.push(current);
730
+ }
731
+ return chunks;
732
+ };
676
733
  const LEGACY_CACHE_FILENAME = 'parse-cache.json';
677
734
  const CACHE_DIRNAME = 'parse-cache';
678
735
  const CACHE_INDEX_FILENAME = 'index.json';
679
736
  /** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
680
737
  const CHUNK_CACHE_KEY_HEX_RE = /^[a-f0-9]{64}$/;
681
738
  const isValidChunkCacheKey = (chunkHash) => CHUNK_CACHE_KEY_HEX_RE.test(chunkHash);
682
- /** SHA-256 hex of a single string or buffer. */
683
- const sha256Hex = (input) => createHash('sha256')
684
- .update(typeof input === 'string' ? Buffer.from(input) : input)
685
- .digest('hex');
686
739
  /** Stable hash of a single file's contents — used by callers to compose a chunk hash. */
687
740
  export const fileContentHash = (content) => sha256Hex(content);
688
741
  export const computeChunkHash = (entries, pdg = false) => {
@@ -72,6 +72,15 @@ export declare const forceGc: () => void;
72
72
  export declare const getParsedFileStoreDir: (storagePath: string) => string;
73
73
  /** Remove any prior run's shards so a fresh parse starts clean. Idempotent. */
74
74
  export declare const clearParsedFileStore: (storagePath: string) => Promise<void>;
75
+ /**
76
+ * Test seam for #3086. Production always calls {@link forceGc}; unit tests
77
+ * replace `run` to count cadence without requiring `--expose-gc`.
78
+ */
79
+ export declare const parsedFileLoadGc: {
80
+ run: () => void;
81
+ /** Raw UTF-8 JSON shard bytes between GCs (#3086). Tests may lower this. */
82
+ byteBudget: number;
83
+ };
75
84
  /**
76
85
  * Write one parse chunk's `ParsedFile[]` to the store as a single shard (async).
77
86
  * No-op for an empty chunk. `shardId` must be unique within a run. Used by the
@@ -47,7 +47,7 @@
47
47
  * on a cold run. Content-addressing makes stale reuse impossible: a changed
48
48
  * file changes its chunk hash, which misses BOTH stores and re-dispatches.
49
49
  */
50
- import { promises as fs, mkdirSync, writeFileSync } from 'node:fs';
50
+ import { promises as fs, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
51
51
  import path from 'node:path';
52
52
  import v8 from 'node:v8';
53
53
  import vm from 'node:vm';
@@ -162,6 +162,119 @@ const serializeParsedFileShard = (parsedFiles) => {
162
162
  return JSON.stringify(parsedFiles, mapReplacer);
163
163
  };
164
164
  const shardPath = (storagePath, shardId) => path.join(getParsedFileStoreDir(storagePath), `${shardId}.json`);
165
+ /** Sidecar listing `filePath`s in a shard; not matched by `endsWith('.json')`. */
166
+ const shardPathsSidecarPath = (jsonPath) => `${jsonPath}.paths`;
167
+ const LOAD_YIELD_EVERY_SHARDS = 128;
168
+ /**
169
+ * Test seam for #3086. Production always calls {@link forceGc}; unit tests
170
+ * replace `run` to count cadence without requiring `--expose-gc`.
171
+ */
172
+ export const parsedFileLoadGc = {
173
+ run: forceGc,
174
+ /** Raw UTF-8 JSON shard bytes between GCs (#3086). Tests may lower this. */
175
+ byteBudget: 128 * 1024 * 1024,
176
+ };
177
+ const encodeShardPathsSidecar = (parsedFiles) => {
178
+ const paths = parsedFiles.map((pf) => pf.filePath);
179
+ return `${paths.length}\n${paths.length === 0 ? '' : `${paths.join('\n')}\n`}`;
180
+ };
181
+ /**
182
+ * Parse a counted NDJSON path listing. Returns `null` when the sidecar must
183
+ * not be trusted to skip the JSON shard: missing trailing newline, CR/NUL,
184
+ * a truncated listing that still ends on a complete line, or a count that
185
+ * does not match the remaining lines.
186
+ */
187
+ const parseShardPathsSidecar = (sidecarRaw) => {
188
+ if (sidecarRaw.includes('\0') || sidecarRaw.includes('\r') || !sidecarRaw.endsWith('\n')) {
189
+ return null;
190
+ }
191
+ const nl = sidecarRaw.indexOf('\n');
192
+ if (nl < 0)
193
+ return null;
194
+ const countToken = sidecarRaw.slice(0, nl);
195
+ if (!/^[0-9]+$/.test(countToken))
196
+ return null;
197
+ const count = Number(countToken);
198
+ const body = sidecarRaw.slice(nl + 1);
199
+ const listed = body === '' ? [] : body.slice(0, -1).split('\n');
200
+ if (listed.length !== count)
201
+ return null;
202
+ return listed;
203
+ };
204
+ /** NDJSON sidecars cannot encode paths that themselves contain CR/LF/NUL. */
205
+ const shardPathsSidecarSafe = (parsedFiles) => parsedFiles.every((pf) => !/[\r\n\0]/.test(pf.filePath));
206
+ const isEnoent = (err) => err.code === 'ENOENT';
207
+ const warnSidecarIo = (err, jsonPath, msg) => {
208
+ logger.warn({ err, jsonPath }, msg);
209
+ };
210
+ const ignoreMissingSidecarUnlink = (err, jsonPath) => {
211
+ if (isEnoent(err))
212
+ return;
213
+ warnSidecarIo(err, jsonPath, 'parsedfile-store: failed to drop path sidecar; JSON remains authoritative');
214
+ };
215
+ /** Drop a leftover listing before publishing JSON so load cannot skip new paths. */
216
+ const dropPathSidecar = async (jsonPath) => {
217
+ try {
218
+ await fs.unlink(shardPathsSidecarPath(jsonPath));
219
+ }
220
+ catch (err) {
221
+ ignoreMissingSidecarUnlink(err, jsonPath);
222
+ }
223
+ };
224
+ const dropPathSidecarSync = (jsonPath) => {
225
+ try {
226
+ unlinkSync(shardPathsSidecarPath(jsonPath));
227
+ }
228
+ catch (err) {
229
+ ignoreMissingSidecarUnlink(err, jsonPath);
230
+ }
231
+ };
232
+ const writeShardPathsSidecar = async (jsonPath, parsedFiles) => {
233
+ if (!shardPathsSidecarSafe(parsedFiles)) {
234
+ try {
235
+ await fs.unlink(shardPathsSidecarPath(jsonPath));
236
+ }
237
+ catch (err) {
238
+ ignoreMissingSidecarUnlink(err, jsonPath);
239
+ }
240
+ return;
241
+ }
242
+ try {
243
+ await fs.writeFile(shardPathsSidecarPath(jsonPath), encodeShardPathsSidecar(parsedFiles), 'utf-8');
244
+ }
245
+ catch (err) {
246
+ warnSidecarIo(err, jsonPath, 'parsedfile-store: path sidecar write failed; JSON shard remains authoritative');
247
+ try {
248
+ await fs.unlink(shardPathsSidecarPath(jsonPath));
249
+ }
250
+ catch (unlinkErr) {
251
+ ignoreMissingSidecarUnlink(unlinkErr, jsonPath);
252
+ }
253
+ }
254
+ };
255
+ const writeShardPathsSidecarSync = (jsonPath, parsedFiles) => {
256
+ if (!shardPathsSidecarSafe(parsedFiles)) {
257
+ try {
258
+ unlinkSync(shardPathsSidecarPath(jsonPath));
259
+ }
260
+ catch (err) {
261
+ ignoreMissingSidecarUnlink(err, jsonPath);
262
+ }
263
+ return;
264
+ }
265
+ try {
266
+ writeFileSync(shardPathsSidecarPath(jsonPath), encodeShardPathsSidecar(parsedFiles), 'utf-8');
267
+ }
268
+ catch (err) {
269
+ warnSidecarIo(err, jsonPath, 'parsedfile-store: path sidecar write failed; JSON shard remains authoritative');
270
+ try {
271
+ unlinkSync(shardPathsSidecarPath(jsonPath));
272
+ }
273
+ catch (unlinkErr) {
274
+ ignoreMissingSidecarUnlink(unlinkErr, jsonPath);
275
+ }
276
+ }
277
+ };
165
278
  /**
166
279
  * Write one parse chunk's `ParsedFile[]` to the store as a single shard (async).
167
280
  * No-op for an empty chunk. `shardId` must be unique within a run. Used by the
@@ -173,7 +286,10 @@ export const persistParsedFileChunk = async (storagePath, shardId, parsedFiles)
173
286
  if (payload === null)
174
287
  return;
175
288
  await fs.mkdir(getParsedFileStoreDir(storagePath), { recursive: true });
176
- await fs.writeFile(shardPath(storagePath, shardId), payload, 'utf-8');
289
+ const dest = shardPath(storagePath, shardId);
290
+ await dropPathSidecar(dest);
291
+ await fs.writeFile(dest, payload, 'utf-8');
292
+ await writeShardPathsSidecar(dest, parsedFiles);
177
293
  };
178
294
  // Per-process set of store dirs we've already `mkdir`ed, so the sync worker
179
295
  // writer (called once per job, many times into the same dir) doesn't issue a
@@ -197,7 +313,10 @@ export const persistParsedFileShardSync = (storagePath, shardId, parsedFiles) =>
197
313
  mkdirSync(dir, { recursive: true });
198
314
  createdStoreDirs.add(dir);
199
315
  }
200
- writeFileSync(shardPath(storagePath, shardId), payload, 'utf-8');
316
+ const dest = shardPath(storagePath, shardId);
317
+ dropPathSidecarSync(dest);
318
+ writeFileSync(dest, payload, 'utf-8');
319
+ writeShardPathsSidecarSync(dest, parsedFiles);
201
320
  };
202
321
  /**
203
322
  * Stream the store and return the `ParsedFile`s whose `filePath` is in
@@ -227,58 +346,95 @@ export const loadParsedFilesForPaths = async (storagePath, wantPaths) => {
227
346
  let filesWithDroppedSites = 0;
228
347
  let droppedChains = 0;
229
348
  let rejectedFiles = 0;
349
+ let bytesSinceGc = 0;
350
+ let shardsSinceYield = 0;
351
+ const maybeYieldAndGc = async (forceByteGc) => {
352
+ if (forceByteGc) {
353
+ parsedFileLoadGc.run();
354
+ bytesSinceGc = 0;
355
+ shardsSinceYield = 0;
356
+ await new Promise((resolve) => setImmediate(resolve));
357
+ return;
358
+ }
359
+ shardsSinceYield++;
360
+ if (shardsSinceYield >= LOAD_YIELD_EVERY_SHARDS) {
361
+ shardsSinceYield = 0;
362
+ await new Promise((resolve) => setImmediate(resolve));
363
+ }
364
+ };
230
365
  for (let i = 0; i < shards.length; i++) {
366
+ const jsonName = shards[i];
367
+ const jsonFull = path.join(dir, jsonName);
368
+ try {
369
+ const sidecarRaw = await fs.readFile(shardPathsSidecarPath(jsonFull), 'utf-8');
370
+ // Fail closed: complete writers emit `<count>\n` plus one path per line
371
+ // and a trailing newline, never CR. Stripping CR (or accepting a
372
+ // newline-terminated prefix) would let a truncated listing skip JSON.
373
+ const listed = parseShardPathsSidecar(sidecarRaw);
374
+ if (listed === null) {
375
+ throw new Error('corrupt sidecar');
376
+ }
377
+ if (listed.length > 0 && !listed.some((p) => wantPaths.has(p))) {
378
+ await maybeYieldAndGc(false);
379
+ continue;
380
+ }
381
+ }
382
+ catch {
383
+ // Missing or unreadable sidecar → read the shard (pre-sidecar stores).
384
+ }
231
385
  // Per-shard def pool: a SymbolDefinition's three serialized copies live within
232
386
  // a single shard (one ParsedFile), so the dedup is shard-local. A cross-shard
233
387
  // pool would retain defs of files NOT in `wantPaths` (loaded-but-discarded
234
388
  // shards), reintroducing the leak; per-shard drops them with the shard.
235
389
  const defPool = new Map();
236
390
  const reviver = makeInterningReviver(pool, defPool);
391
+ let raw;
392
+ try {
393
+ raw = await fs.readFile(jsonFull, 'utf-8');
394
+ }
395
+ catch {
396
+ continue; // skip a missing shard; missing files fall back to fresh extract
397
+ }
398
+ bytesSinceGc += Buffer.byteLength(raw, 'utf8');
399
+ const crossedBudget = bytesSinceGc >= parsedFileLoadGc.byteBudget;
237
400
  let parsed;
238
401
  try {
239
- const raw = await fs.readFile(path.join(dir, shards[i]), 'utf-8');
240
402
  parsed = JSON.parse(raw, reviver);
241
403
  }
242
404
  catch {
243
- continue; // skip a corrupt shard; missing files fall back to fresh extract
405
+ parsed = undefined;
244
406
  }
245
- if (!Array.isArray(parsed))
246
- continue;
247
- for (const pf of parsed) {
248
- if (!pf || typeof pf.filePath !== 'string' || !wantPaths.has(pf.filePath))
249
- continue;
250
- const flow = sanitizeCallableFlowSites(pf.callableFlowSites);
251
- if (flow === undefined) {
252
- // non-array garbage → distrust the file, re-extract
253
- rejectedFiles++;
254
- continue;
255
- }
256
- const chains = sanitizeReceiverChains(pf.referenceSites);
257
- if (chains === undefined) {
258
- rejectedFiles++;
259
- continue;
260
- }
261
- if (flow.dropped === 0 && chains.dropped === 0) {
262
- out.set(pf.filePath, pf);
263
- }
264
- else {
265
- droppedSites += flow.dropped;
266
- droppedChains += chains.dropped;
267
- filesWithDroppedSites++;
268
- out.set(pf.filePath, {
269
- ...pf,
270
- ...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }),
271
- ...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }),
272
- });
407
+ if (Array.isArray(parsed)) {
408
+ for (const pf of parsed) {
409
+ if (!pf || typeof pf.filePath !== 'string' || !wantPaths.has(pf.filePath))
410
+ continue;
411
+ const flow = sanitizeCallableFlowSites(pf.callableFlowSites);
412
+ if (flow === undefined) {
413
+ // non-array garbage → distrust the file, re-extract
414
+ rejectedFiles++;
415
+ continue;
416
+ }
417
+ const chains = sanitizeReceiverChains(pf.referenceSites);
418
+ if (chains === undefined) {
419
+ rejectedFiles++;
420
+ continue;
421
+ }
422
+ if (flow.dropped === 0 && chains.dropped === 0) {
423
+ out.set(pf.filePath, pf);
424
+ }
425
+ else {
426
+ droppedSites += flow.dropped;
427
+ droppedChains += chains.dropped;
428
+ filesWithDroppedSites++;
429
+ out.set(pf.filePath, {
430
+ ...pf,
431
+ ...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }),
432
+ ...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }),
433
+ });
434
+ }
273
435
  }
274
436
  }
275
- // Every few shards, reclaim the transient pre-intern parse churn before it
276
- // piles up against the heap limit (~5 GB avoidable on the kernel), and
277
- // yield so the GC + any pending I/O can run.
278
- if ((i & 7) === 7) {
279
- forceGc();
280
- await new Promise((resolve) => setImmediate(resolve));
281
- }
437
+ await maybeYieldAndGc(crossedBudget);
282
438
  }
283
439
  if (droppedSites > 0 || droppedChains > 0) {
284
440
  // Facts for the dropped sites are omitted this run (the file itself is
@@ -507,7 +663,10 @@ export const persistDurableParsedFileShardSync = (durableDir, chunkHash, threadI
507
663
  mkdirSync(dir, { recursive: true });
508
664
  createdDurableDirs.add(dir);
509
665
  }
510
- writeFileSync(path.join(dir, `${chunkHash}-w${threadId}-${shardSeq}.json`), payload, 'utf-8');
666
+ const dest = path.join(dir, `${chunkHash}-w${threadId}-${shardSeq}.json`);
667
+ dropPathSidecarSync(dest);
668
+ writeFileSync(dest, payload, 'utf-8');
669
+ writeShardPathsSidecarSync(dest, parsedFiles);
511
670
  };
512
671
  /**
513
672
  * Restore a cached chunk's durable shards into the run-scoped store on a warm
@@ -533,7 +692,25 @@ export const restoreDurableParsedFileShard = async (durableDir, runStoragePath,
533
692
  const dst = getParsedFileStoreDir(runStoragePath);
534
693
  await fs.mkdir(dst, { recursive: true });
535
694
  for (const name of shards) {
536
- await fs.copyFile(path.join(src, name), path.join(dst, name));
695
+ const srcJson = path.join(src, name);
696
+ const dstJson = path.join(dst, name);
697
+ await dropPathSidecar(dstJson);
698
+ await fs.copyFile(srcJson, dstJson);
699
+ try {
700
+ await fs.copyFile(shardPathsSidecarPath(srcJson), shardPathsSidecarPath(dstJson));
701
+ }
702
+ catch (copyErr) {
703
+ if (!isEnoent(copyErr)) {
704
+ warnSidecarIo(copyErr, srcJson, 'parsedfile-store: durable path sidecar copy failed; JSON remains authoritative');
705
+ continue;
706
+ }
707
+ try {
708
+ await fs.unlink(shardPathsSidecarPath(dstJson));
709
+ }
710
+ catch (err) {
711
+ ignoreMissingSidecarUnlink(err, dstJson);
712
+ }
713
+ }
537
714
  }
538
715
  return shards.length;
539
716
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.11-rc.11",
3
+ "version": "1.6.11-rc.13",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",