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.
- package/dist/cli/i18n/en.d.ts +1 -1
- package/dist/cli/i18n/en.js +1 -1
- package/dist/cli/i18n/resources.d.ts +1 -1
- package/dist/cli/i18n/zh-CN.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +1 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +29 -68
- package/dist/server/api.js +4 -1
- package/dist/server/mcp-http.d.ts +8 -0
- package/dist/server/mcp-http.js +16 -1
- package/dist/storage/parse-cache.d.ts +22 -4
- package/dist/storage/parse-cache.js +62 -9
- package/dist/storage/parsedfile-store.d.ts +9 -0
- package/dist/storage/parsedfile-store.js +219 -42
- package/package.json +1 -1
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -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";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -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':
|
|
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";
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -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
|
|
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>',
|
|
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
|
|
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
|
|
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
|
|
114
|
+
/** Max bytes of source content to load per parse cache pack.
|
|
115
115
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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
|
-
|
|
434
|
-
//
|
|
435
|
-
// worker
|
|
436
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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);
|
package/dist/server/api.js
CHANGED
|
@@ -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>>;
|
package/dist/server/mcp-http.js
CHANGED
|
@@ -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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
parsed = undefined;
|
|
244
406
|
}
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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