gitnexus 1.6.4-rc.80 → 1.6.4-rc.81

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.
@@ -16,6 +16,8 @@ import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, Ana
16
16
  import { getGitRoot, hasGitDir } from '../storage/git.js';
17
17
  import { runFullAnalysis } from '../core/run-analyze.js';
18
18
  import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
19
+ import { warnMissingOptionalGrammars } from './optional-grammars.js';
20
+ import { glob } from 'glob';
19
21
  import fs from 'fs/promises';
20
22
  // Capture stderr.write at module load BEFORE anything (LadybugDB native
21
23
  // init, progress bar, console redirection) can monkey-patch it. The
@@ -173,6 +175,31 @@ export const analyzeCommand = async (inputPath, options) => {
173
175
  if (!repoHasGit) {
174
176
  console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
175
177
  }
178
+ // If the target repo contains files an optional grammar would parse but
179
+ // that grammar's native binding is absent, warn before analysis so users
180
+ // learn why those files end up unparsed instead of silently getting a
181
+ // degraded index.
182
+ try {
183
+ const matches = await glob(['**/*.dart', '**/*.proto'], {
184
+ cwd: repoPath,
185
+ ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
186
+ dot: false,
187
+ nodir: true,
188
+ absolute: false,
189
+ });
190
+ if (matches.length > 0) {
191
+ const present = new Set();
192
+ for (const m of matches) {
193
+ const ext = path.extname(m).toLowerCase();
194
+ if (ext)
195
+ present.add(ext);
196
+ }
197
+ warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
198
+ }
199
+ }
200
+ catch {
201
+ // Best-effort warning \u2014 never block analyze on the precheck.
202
+ }
176
203
  // KuzuDB migration cleanup is handled by runFullAnalysis internally.
177
204
  // Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
178
205
  if (process.env.GITNEXUS_NO_GITIGNORE) {
package/dist/cli/mcp.d.ts CHANGED
@@ -4,5 +4,26 @@
4
4
  * Starts the MCP server in standalone mode.
5
5
  * Loads all indexed repos from the global registry.
6
6
  * No longer depends on cwd — works from any directory.
7
+ *
8
+ * IMPORTANT: this module's static-import closure is intentionally tiny
9
+ * (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
10
+ * leaf with zero non-`node:` imports). All heavy backend modules
11
+ * (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
12
+ * via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
13
+ *
14
+ * This closes the ESM-evaluation-order window where native init banners
15
+ * from `@ladybugdb/core` (or any future heavy import) could reach raw
16
+ * stdout before the sentinel exists. Codex's adversarial review on
17
+ * PR #1383 found that even with the sentinel-install call as the first
18
+ * statement of `mcpCommand`, ESM evaluates static imports of THIS module
19
+ * before the function body runs — so any native side effects during
20
+ * those imports happen before the sentinel can intercept them.
21
+ *
22
+ * If you find yourself adding a static `import` to this file, ask
23
+ * whether the imported module (or anything it transitively imports)
24
+ * touches `process.stdout` or loads a native binding at module init. If
25
+ * either is true, switch it to a dynamic `await import(...)` inside
26
+ * `mcpCommand` after the sentinel install. The regression test at
27
+ * `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
7
28
  */
8
29
  export declare const mcpCommand: () => Promise<void>;
package/dist/cli/mcp.js CHANGED
@@ -4,21 +4,50 @@
4
4
  * Starts the MCP server in standalone mode.
5
5
  * Loads all indexed repos from the global registry.
6
6
  * No longer depends on cwd — works from any directory.
7
+ *
8
+ * IMPORTANT: this module's static-import closure is intentionally tiny
9
+ * (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
10
+ * leaf with zero non-`node:` imports). All heavy backend modules
11
+ * (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
12
+ * via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
13
+ *
14
+ * This closes the ESM-evaluation-order window where native init banners
15
+ * from `@ladybugdb/core` (or any future heavy import) could reach raw
16
+ * stdout before the sentinel exists. Codex's adversarial review on
17
+ * PR #1383 found that even with the sentinel-install call as the first
18
+ * statement of `mcpCommand`, ESM evaluates static imports of THIS module
19
+ * before the function body runs — so any native side effects during
20
+ * those imports happen before the sentinel can intercept them.
21
+ *
22
+ * If you find yourself adding a static `import` to this file, ask
23
+ * whether the imported module (or anything it transitively imports)
24
+ * touches `process.stdout` or loads a native binding at module init. If
25
+ * either is true, switch it to a dynamic `await import(...)` inside
26
+ * `mcpCommand` after the sentinel install. The regression test at
27
+ * `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
7
28
  */
8
- import { startMCPServer } from '../mcp/server.js';
9
- import { LocalBackend } from '../mcp/local/local-backend.js';
29
+ import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
10
30
  export const mcpCommand = async () => {
11
- // Prevent unhandled errors from crashing the MCP server process.
12
- // LadybugDB lock conflicts and transient errors should degrade gracefully.
13
- process.on('uncaughtException', (err) => {
14
- console.error(`GitNexus MCP: uncaught exception ${err.message}`);
15
- // Process is in an undefined state after uncaughtException — exit after flushing
16
- setTimeout(() => process.exit(1), 100);
17
- });
18
- process.on('unhandledRejection', (reason) => {
19
- const msg = reason instanceof Error ? reason.message : String(reason);
20
- console.error(`GitNexus MCP: unhandled rejection ${msg}`);
21
- });
31
+ // Install the global stdout sentinel as the very first thing — before
32
+ // ANY other module loads. The static-import closure above is leaf-only
33
+ // (stdio-context → stdio-capture, zero non-`node:` deps), so this is
34
+ // also the first chance any code in this process has to write to stdout.
35
+ installGlobalStdoutSentinel();
36
+ // uncaughtException/unhandledRejection handlers are owned by
37
+ // startMCPServer (gitnexus/src/mcp/server.ts) so the server's shutdown
38
+ // path runs cleanly with full stack traces. Registering duplicates here
39
+ // would only produce noisy double-logging on the same exception.
40
+ // Now safe to dynamically import the heavy backend modules. Anything
41
+ // they emit to stdout during evaluation will route through the sentinel.
42
+ const [{ startMCPServer }, { LocalBackend }] = await Promise.all([
43
+ import('../mcp/server.js'),
44
+ import('../mcp/local/local-backend.js'),
45
+ ]);
46
+ // Missing-optional-grammar warnings are intentionally NOT emitted here.
47
+ // `gitnexus analyze` already warns at index time, filtered by the repo's
48
+ // actual extensions, and a repo can only be served by MCP after analyze
49
+ // has run. Repeating an unconditional warning at every MCP startup is
50
+ // pure noise for users whose indexed repos don't use Dart/Proto.
22
51
  // Initialize multi-repo backend from registry.
23
52
  // The server starts even with 0 repos — tools call refreshRepos() lazily,
24
53
  // so repos indexed after the server starts are discovered automatically.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Optional grammar availability check.
3
+ *
4
+ * tree-sitter-dart and tree-sitter-proto are optionalDependencies that
5
+ * require a `node-gyp rebuild` at install time. The build can be skipped
6
+ * via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
7
+ * silently soft-fail when the C++ toolchain is missing.
8
+ *
9
+ * Either path produces the same observable: the .node binding is absent
10
+ * at runtime. This helper detects that condition and surfaces a single
11
+ * stderr line per missing grammar so users learn why .dart/.proto support
12
+ * is unavailable instead of silently getting a degraded index.
13
+ */
14
+ export interface MissingGrammar {
15
+ name: string;
16
+ extensions: string[];
17
+ }
18
+ /**
19
+ * Returns the list of optional grammars whose native binding cannot be
20
+ * loaded. Actually `require()`s the package — `require.resolve` would
21
+ * locate the entry path even when the `.node` binding is absent (the
22
+ * `file:` package directory is installed regardless of postinstall
23
+ * outcome), giving false negatives for the exact users we want to warn:
24
+ * those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
25
+ * native rebuild soft-failed for missing toolchain.
26
+ *
27
+ * Node's module cache memoizes `require()` for us — calling this multiple
28
+ * times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
29
+ * the typical node-gyp-build "could not find any binding" pattern) from
30
+ * "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
31
+ * separate stderr line so users get an actionable message instead of a
32
+ * misleading "reinstall" hint.
33
+ */
34
+ export declare function detectMissingOptionalGrammars(): MissingGrammar[];
35
+ /**
36
+ * Log a one-line stderr warning for each missing grammar. Safe to call
37
+ * unconditionally — silent if all grammars are present.
38
+ *
39
+ * `relevantExtensions`, if provided, filters the warning to grammars whose
40
+ * extensions appear in the set (e.g. an analyze run can pass the set of
41
+ * extensions actually present in the target repo so users without any
42
+ * .dart/.proto files don't see noise).
43
+ */
44
+ export declare function warnMissingOptionalGrammars(opts?: {
45
+ context?: string;
46
+ relevantExtensions?: ReadonlySet<string>;
47
+ }): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Optional grammar availability check.
3
+ *
4
+ * tree-sitter-dart and tree-sitter-proto are optionalDependencies that
5
+ * require a `node-gyp rebuild` at install time. The build can be skipped
6
+ * via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
7
+ * silently soft-fail when the C++ toolchain is missing.
8
+ *
9
+ * Either path produces the same observable: the .node binding is absent
10
+ * at runtime. This helper detects that condition and surfaces a single
11
+ * stderr line per missing grammar so users learn why .dart/.proto support
12
+ * is unavailable instead of silently getting a degraded index.
13
+ */
14
+ import { createRequire } from 'module';
15
+ const _require = createRequire(import.meta.url);
16
+ const OPTIONAL_GRAMMARS = [
17
+ { name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
18
+ { name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
19
+ ];
20
+ /**
21
+ * Returns the list of optional grammars whose native binding cannot be
22
+ * loaded. Actually `require()`s the package — `require.resolve` would
23
+ * locate the entry path even when the `.node` binding is absent (the
24
+ * `file:` package directory is installed regardless of postinstall
25
+ * outcome), giving false negatives for the exact users we want to warn:
26
+ * those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
27
+ * native rebuild soft-failed for missing toolchain.
28
+ *
29
+ * Node's module cache memoizes `require()` for us — calling this multiple
30
+ * times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
31
+ * the typical node-gyp-build "could not find any binding" pattern) from
32
+ * "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
33
+ * separate stderr line so users get an actionable message instead of a
34
+ * misleading "reinstall" hint.
35
+ */
36
+ export function detectMissingOptionalGrammars() {
37
+ const missing = [];
38
+ for (const g of OPTIONAL_GRAMMARS) {
39
+ try {
40
+ _require(g.pkg);
41
+ }
42
+ catch (err) {
43
+ const code = err?.code;
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ const looksMissing = code === 'MODULE_NOT_FOUND' ||
46
+ code === 'ERR_MODULE_NOT_FOUND' ||
47
+ /could not find|no native build|prebuilds/i.test(msg);
48
+ if (!looksMissing) {
49
+ // Present but broken — surface so the user doesn't get a misleading
50
+ // "reinstall" recovery message that wouldn't actually help.
51
+ console.error(`GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`);
52
+ }
53
+ missing.push({ name: g.name, extensions: g.extensions });
54
+ }
55
+ }
56
+ return missing;
57
+ }
58
+ /**
59
+ * Log a one-line stderr warning for each missing grammar. Safe to call
60
+ * unconditionally — silent if all grammars are present.
61
+ *
62
+ * `relevantExtensions`, if provided, filters the warning to grammars whose
63
+ * extensions appear in the set (e.g. an analyze run can pass the set of
64
+ * extensions actually present in the target repo so users without any
65
+ * .dart/.proto files don't see noise).
66
+ */
67
+ export function warnMissingOptionalGrammars(opts) {
68
+ const missing = detectMissingOptionalGrammars();
69
+ if (missing.length === 0)
70
+ return;
71
+ const ctx = opts?.context ? ` [${opts.context}]` : '';
72
+ for (const g of missing) {
73
+ if (opts?.relevantExtensions && !g.extensions.some((e) => opts.relevantExtensions.has(e))) {
74
+ continue;
75
+ }
76
+ console.error(`GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`);
77
+ }
78
+ }
package/dist/cli/setup.js CHANGED
@@ -9,6 +9,7 @@ import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import os from 'os';
11
11
  import { execFile, execFileSync } from 'child_process';
12
+ import { createRequire } from 'module';
12
13
  import { promisify } from 'util';
13
14
  import { fileURLToPath } from 'url';
14
15
  import { glob } from 'glob';
@@ -17,6 +18,18 @@ import { getGlobalDir } from '../storage/repo-manager.js';
17
18
  const __filename = fileURLToPath(import.meta.url);
18
19
  const __dirname = path.dirname(__filename);
19
20
  const execFileAsync = promisify(execFile);
21
+ // Pin the npx fallback to the installed version. Reason: setup.ts writes
22
+ // a config that persists in the user's editor and is invoked on every MCP
23
+ // connect. Pinning to the installed version means subsequent invocations
24
+ // skip the npm-registry metadata roundtrip (and stay reproducible until
25
+ // the user upgrades). Static configs and READMEs intentionally use
26
+ // `gitnexus@latest` since they're quickstart docs, not persisted state.
27
+ const _require = createRequire(import.meta.url);
28
+ const _pkg = _require('../../package.json');
29
+ if (typeof _pkg.version !== 'string' || !_pkg.version) {
30
+ throw new Error('gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.');
31
+ }
32
+ const NPX_REF = `gitnexus@${_pkg.version}`;
20
33
  /**
21
34
  * Resolve the absolute path to the `gitnexus` binary if it's installed
22
35
  * globally (or via npm -g / yarn global). Returns null when not found.
@@ -51,8 +64,10 @@ function resolveGitnexusBin() {
51
64
  * The MCP server entry for all editors.
52
65
  *
53
66
  * Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
54
- * `npx -y gitnexus@latest` (cold-cache install of native deps can take
55
- * >60 s, exceeding Claude Code's 30 s MCP connection timeout).
67
+ * `npx -y gitnexus@<version>` (cold-cache install of native deps can take
68
+ * >60 s, exceeding Claude Code's 30 s MCP connection timeout). The fallback
69
+ * version is read from gitnexus/package.json#version at module load so the
70
+ * persisted user config matches the installed package.
56
71
  *
57
72
  * Falls back to npx when the binary isn't on PATH — e.g. first-time
58
73
  * users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
@@ -66,12 +81,12 @@ function getMcpEntry() {
66
81
  if (process.platform === 'win32') {
67
82
  return {
68
83
  command: 'cmd',
69
- args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
84
+ args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
70
85
  };
71
86
  }
72
87
  return {
73
88
  command: 'npx',
74
- args: ['-y', 'gitnexus@latest', 'mcp'],
89
+ args: ['-y', NPX_REF, 'mcp'],
75
90
  };
76
91
  }
77
92
  /**
@@ -84,9 +99,9 @@ function getOpenCodeMcpEntry() {
84
99
  return { type: 'local', command: [bin, 'mcp'] };
85
100
  }
86
101
  if (process.platform === 'win32') {
87
- return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
102
+ return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
88
103
  }
89
- return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
104
+ return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
90
105
  }
91
106
  /**
92
107
  * Detect indentation style from file content.
@@ -139,7 +139,7 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
139
139
  applyHfEnvOverrides(env);
140
140
  const isDev = process.env.NODE_ENV === 'development';
141
141
  if (isDev) {
142
- console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
142
+ console.error(`🧠 Loading embedding model: ${finalConfig.modelId}`);
143
143
  }
144
144
  const progressCallback = onProgress
145
145
  ? (data) => {
@@ -161,16 +161,16 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
161
161
  for (const device of devicesToTry) {
162
162
  try {
163
163
  if (isDev && device === 'dml') {
164
- console.log('🔧 Trying DirectML (DirectX12) GPU backend...');
164
+ console.error('🔧 Trying DirectML (DirectX12) GPU backend...');
165
165
  }
166
166
  else if (isDev && device === 'cuda') {
167
- console.log('🔧 Trying CUDA GPU backend...');
167
+ console.error('🔧 Trying CUDA GPU backend...');
168
168
  }
169
169
  else if (isDev && device === 'cpu') {
170
- console.log('🔧 Using CPU backend...');
170
+ console.error('🔧 Using CPU backend...');
171
171
  }
172
172
  else if (isDev && device === 'wasm') {
173
- console.log('🔧 Using WASM backend (slower)...');
173
+ console.error('🔧 Using WASM backend (slower)...');
174
174
  }
175
175
  embedderInstance = await pipeline('feature-extraction', finalConfig.modelId, {
176
176
  device: device,
@@ -190,15 +190,15 @@ export const initEmbedder = async (onProgress, config = {}, forceDevice) => {
190
190
  : device === 'cuda'
191
191
  ? 'GPU (CUDA)'
192
192
  : device.toUpperCase();
193
- console.log(`✅ Using ${label} backend`);
194
- console.log('✅ Embedding model loaded successfully');
193
+ console.error(`✅ Using ${label} backend`);
194
+ console.error('✅ Embedding model loaded successfully');
195
195
  }
196
196
  return embedderInstance;
197
197
  }
198
198
  catch (deviceError) {
199
199
  if (isDev && (device === 'cuda' || device === 'dml')) {
200
200
  const gpuType = device === 'dml' ? 'DirectML' : 'CUDA';
201
- console.log(`⚠️ ${gpuType} not available, falling back to CPU...`);
201
+ console.error(`⚠️ ${gpuType} not available, falling back to CPU...`);
202
202
  }
203
203
  // Continue to next device in list
204
204
  if (device === devicesToTry[devicesToTry.length - 1]) {
@@ -112,7 +112,7 @@ const queryEmbeddableNodes = async (executeQuery) => {
112
112
  }
113
113
  catch (error) {
114
114
  if (isDev) {
115
- console.warn(`Query for ${label} nodes failed:`, error);
115
+ console.error(`Query for ${label} nodes failed:`, error);
116
116
  }
117
117
  }
118
118
  }
@@ -151,7 +151,7 @@ const createVectorIndex = async (executeQuery) => {
151
151
  }
152
152
  catch (error) {
153
153
  if (isDev) {
154
- console.warn('Vector index creation warning:', error);
154
+ console.error('Vector index creation warning:', error);
155
155
  }
156
156
  return false;
157
157
  }
@@ -176,7 +176,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
176
176
  try {
177
177
  const vectorAvailable = await ensureVectorExtensionAvailable();
178
178
  if (!vectorAvailable && isDev)
179
- console.warn(vectorUnavailableMessage);
179
+ console.error(vectorUnavailableMessage);
180
180
  // Phase 1: Load embedding model
181
181
  onProgress({
182
182
  phase: 'loading-model',
@@ -199,7 +199,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
199
199
  modelDownloadPercent: 100,
200
200
  });
201
201
  if (isDev) {
202
- console.log('🔍 Querying embeddable nodes...');
202
+ console.error('🔍 Querying embeddable nodes...');
203
203
  }
204
204
  // Phase 2: Query embeddable nodes
205
205
  let nodes = await queryEmbeddableNodes(executeQuery);
@@ -237,7 +237,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
237
237
  // (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
238
238
  if (staleNodeIds.length > 0) {
239
239
  if (isDev) {
240
- console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
240
+ console.error(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
241
241
  }
242
242
  try {
243
243
  await executeWithReusedStatement(`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) DELETE e`, staleNodeIds.map((nodeId) => ({ nodeId })));
@@ -253,12 +253,12 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
253
253
  }
254
254
  }
255
255
  if (isDev) {
256
- console.log(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`);
256
+ console.error(`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`);
257
257
  }
258
258
  }
259
259
  const totalNodes = nodes.length;
260
260
  if (isDev) {
261
- console.log(`📊 Found ${totalNodes} embeddable nodes`);
261
+ console.error(`📊 Found ${totalNodes} embeddable nodes`);
262
262
  }
263
263
  if (totalNodes === 0) {
264
264
  // Ensure the vector index exists even when no new nodes need embedding.
@@ -324,7 +324,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
324
324
  }
325
325
  catch (chunkErr) {
326
326
  if (isDev) {
327
- console.warn(`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`, chunkErr);
327
+ console.error(`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`, chunkErr);
328
328
  }
329
329
  chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
330
330
  }
@@ -382,7 +382,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
382
382
  totalNodes,
383
383
  });
384
384
  if (isDev) {
385
- console.log('📇 Creating vector index...');
385
+ console.error('📇 Creating vector index...');
386
386
  }
387
387
  const vectorIndexReady = await createVectorIndex(executeQuery);
388
388
  onProgress({
@@ -392,7 +392,7 @@ export const runEmbeddingPipeline = async (executeQuery, executeWithReusedStatem
392
392
  totalNodes,
393
393
  });
394
394
  if (isDev) {
395
- console.log(`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`);
395
+ console.error(`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`);
396
396
  }
397
397
  return {
398
398
  nodesProcessed: totalNodes,
@@ -125,7 +125,7 @@ export class ExtensionManager {
125
125
  }
126
126
  const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv();
127
127
  const timeoutMs = opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
128
- const warn = this.options.warn ?? console.warn;
128
+ const warn = this.options.warn ?? console.error;
129
129
  if (policy === 'never') {
130
130
  this.markUnavailable(name, label, 'extension install policy is "never"', warn);
131
131
  return false;
@@ -274,7 +274,7 @@ const doInitLbug = async (dbPath) => {
274
274
  catch (err) {
275
275
  const msg = err instanceof Error ? err.message : String(err);
276
276
  if (!msg.includes('already exists')) {
277
- console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
277
+ console.error(`[gitnexus:lbug] schema creation warning: ${msg.slice(0, 120)}`);
278
278
  }
279
279
  }
280
280
  }
@@ -889,13 +889,13 @@ export const fetchExistingEmbeddingHashes = async (execQuery) => {
889
889
  if (nodeId)
890
890
  map.set(nodeId, STALE_HASH_SENTINEL);
891
891
  }
892
- console.log(`[embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`);
892
+ console.error(`[gitnexus:embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`);
893
893
  return map;
894
894
  }
895
895
  catch (fallbackErr) {
896
896
  const fallbackMsg = fallbackErr?.message ?? '';
897
897
  if (isMissingColumnOrTableError(fallbackMsg)) {
898
- console.log(`[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`);
898
+ console.error(`[gitnexus:embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`);
899
899
  return undefined;
900
900
  }
901
901
  throw fallbackErr;
@@ -31,9 +31,7 @@ type PoolCloseListener = (repoId: string) => void;
31
31
  * listener (handy for tests).
32
32
  */
33
33
  export declare function addPoolCloseListener(listener: PoolCloseListener): () => void;
34
- /** Saved real stdout/stderr write used to silence native module output without race conditions */
35
- export declare const realStdoutWrite: any;
36
- export declare const realStderrWrite: any;
34
+ export { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from '../../mcp/stdio-capture.js';
37
35
  /**
38
36
  * Touch a repo to reset its idle timeout.
39
37
  * Call this during long-running operations to prevent the connection from being closed.
@@ -90,4 +88,3 @@ export declare const isLbugReady: (repoId: string) => boolean;
90
88
  export declare const CYPHER_WRITE_RE: RegExp;
91
89
  /** Check if a Cypher query contains write operations */
92
90
  export declare function isWriteQuery(query: string): boolean;
93
- export {};
@@ -38,9 +38,20 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
38
38
  /** Max connections per repo (caps concurrent queries per repo) */
39
39
  const MAX_CONNS_PER_REPO = 8;
40
40
  let idleTimer = null;
41
- /** Saved real stdout/stderr write used to silence native module output without race conditions */
42
- export const realStdoutWrite = process.stdout.write.bind(process.stdout);
43
- export const realStderrWrite = process.stderr.write.bind(process.stderr);
41
+ // Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts`a leaf
42
+ // module with zero non-`node:` imports. We re-export the same symbols here
43
+ // so the existing test mock seam (`gitnexus/src/mcp/core/lbug-adapter.ts`
44
+ // re-exports * from this file, and 8+ test files use that path with
45
+ // `vi.mock(...)`) continues to work without churn. The source of truth is
46
+ // the leaf module; this re-export is a compatibility shim.
47
+ //
48
+ // Why the leaf module exists: Codex's adversarial review on PR #1383 found
49
+ // that putting this state in pool-adapter.ts pulled `@ladybugdb/core` into
50
+ // `cli/mcp.ts`'s static-import closure (via stdio-context → pool-adapter →
51
+ // @ladybugdb/core), corrupting stdout in the pre-sentinel window. Routing
52
+ // through the leaf breaks that chain.
53
+ export { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from '../../mcp/stdio-capture.js';
54
+ import { getActiveStdoutWrite } from '../../mcp/stdio-capture.js';
44
55
  let stdoutSilenceCount = 0;
45
56
  /** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
46
57
  let preWarmActive = false;
@@ -155,13 +166,15 @@ let activeQueryCount = 0;
155
166
  */
156
167
  export function silenceStdout() {
157
168
  if (stdoutSilenceCount++ === 0) {
169
+ // eslint-disable-next-line no-restricted-syntax -- silencing infrastructure; replacement is a no-op
158
170
  process.stdout.write = (() => true);
159
171
  }
160
172
  }
161
173
  export function restoreStdout() {
162
174
  if (--stdoutSilenceCount <= 0) {
163
175
  stdoutSilenceCount = 0;
164
- process.stdout.write = realStdoutWrite;
176
+ // eslint-disable-next-line no-restricted-syntax -- restoring the active stdout-write handler is the silencing API contract
177
+ process.stdout.write = getActiveStdoutWrite();
165
178
  }
166
179
  }
167
180
  // Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
@@ -171,7 +184,8 @@ export function restoreStdout() {
171
184
  setInterval(() => {
172
185
  if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
173
186
  stdoutSilenceCount = 0;
174
- process.stdout.write = realStdoutWrite;
187
+ // eslint-disable-next-line no-restricted-syntax -- watchdog recovery for stuck silencing
188
+ process.stdout.write = getActiveStdoutWrite();
175
189
  }
176
190
  }, 1000).unref();
177
191
  function createConnection(db) {
@@ -114,10 +114,10 @@ const logFailure = (key, result) => {
114
114
  return;
115
115
  logged.add(key);
116
116
  const message = `[gitnexus] ${result.note} (${result.error.message})`;
117
- if (result.severity === 'error')
118
- console.error(message);
119
- else
120
- console.warn(message);
117
+ // Both severities go to stderr — console.warn writes to stderr too, but
118
+ // console.error is the stdout-safe channel we standardize on across
119
+ // MCP-reachable code so the ESLint rule covers this directory.
120
+ console.error(message);
121
121
  };
122
122
  export const resolveLanguageKey = (language, filePath) => language === SupportedLanguages.TypeScript && filePath?.endsWith('.tsx')
123
123
  ? `${language}:tsx`
@@ -1,5 +1,6 @@
1
1
  import process from 'node:process';
2
2
  import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { withMcpWrite } from './stdio-context.js';
3
4
  function deserializeMessage(raw) {
4
5
  return JSONRPCMessageSchema.parse(JSON.parse(raw));
5
6
  }
@@ -185,7 +186,12 @@ export class CompatibleStdioServerTransport {
185
186
  reject(error);
186
187
  };
187
188
  this._stdout.on('error', onError);
188
- if (this._stdout.write(payload)) {
189
+ // Tag the write with the MCP transport context so the sentinel
190
+ // (server.ts createStdoutSentinel Proxy) recognizes it as a legitimate
191
+ // JSON-RPC frame and passes it through to the real stdout instead of
192
+ // redirecting to stderr.
193
+ const writeOk = withMcpWrite(() => this._stdout.write(payload));
194
+ if (writeOk) {
189
195
  this._stdout.removeListener('error', onError);
190
196
  resolve();
191
197
  }
@@ -1,5 +1,11 @@
1
1
  /**
2
2
  * LadybugDB connection pool — re-exported from core.
3
- * Prefer importing from `../../core/lbug/pool-adapter.js` in new code.
3
+ *
4
+ * KEEP THIS FILE. It is intentionally a shim re-export of
5
+ * `../../core/lbug/pool-adapter.js`. The MCP test suite uses this path as
6
+ * a vi.mock seam so unit tests can stub LadybugDB without affecting other
7
+ * importers of `core/lbug/pool-adapter.js` (which is shared with the
8
+ * analyze pipeline). New non-test code MAY import from `pool-adapter.js`
9
+ * directly, but the shim must continue to exist for the mock seam to work.
4
10
  */
5
11
  export * from '../../core/lbug/pool-adapter.js';
@@ -1,5 +1,11 @@
1
1
  /**
2
2
  * LadybugDB connection pool — re-exported from core.
3
- * Prefer importing from `../../core/lbug/pool-adapter.js` in new code.
3
+ *
4
+ * KEEP THIS FILE. It is intentionally a shim re-export of
5
+ * `../../core/lbug/pool-adapter.js`. The MCP test suite uses this path as
6
+ * a vi.mock seam so unit tests can stub LadybugDB without affecting other
7
+ * importers of `core/lbug/pool-adapter.js` (which is shared with the
8
+ * analyze pipeline). New non-test code MAY import from `pool-adapter.js`
9
+ * directly, but the shim must continue to exist for the mock seam to work.
4
10
  */
5
11
  export * from '../../core/lbug/pool-adapter.js';
@@ -15,7 +15,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
15
15
  import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
16
16
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
17
17
  import { GITNEXUS_TOOLS } from './tools.js';
18
- import { realStdoutWrite } from './core/lbug-adapter.js';
18
+ import { installGlobalStdoutSentinel } from './stdio-context.js';
19
19
  import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
20
20
  /**
21
21
  * Next-step hints appended to tool responses.
@@ -247,19 +247,30 @@ Follow these steps:
247
247
  */
248
248
  export async function startMCPServer(backend) {
249
249
  const server = createMCPServer(backend);
250
- // Use the shared stdout reference captured at module-load time by the
251
- // lbug-adapter. Avoids divergence if anything patches stdout between
252
- // module load and server start.
253
- const _safeStdout = new Proxy(process.stdout, {
250
+ // Idempotent global sentinel install. cli/mcp.ts calls this first thing
251
+ // (before warnMissingOptionalGrammars / backend.init can emit to stdout);
252
+ // calling again here is a safety net for direct callers of startMCPServer
253
+ // (tests, future entry points). The transport's _safeStdout Proxy is a
254
+ // second layer that guarantees transport writes reach the sentinel even
255
+ // if anything else re-replaces process.stdout.write later. Tagged
256
+ // transport writes (wrapped in withMcpWrite by compatible-stdio-transport.send)
257
+ // pass through to the captured realStdoutWrite; untagged writes reaching
258
+ // the Proxy or process.stdout get redirected to stderr with the
259
+ // [mcp:stdout-redirect] prefix. See stdio-context.ts.
260
+ const sentinel = installGlobalStdoutSentinel();
261
+ const safeStdout = new Proxy(process.stdout, {
254
262
  get(target, prop, receiver) {
255
263
  if (prop === 'write')
256
- return realStdoutWrite;
264
+ return sentinel.write;
257
265
  const val = Reflect.get(target, prop, receiver);
258
266
  return typeof val === 'function' ? val.bind(target) : val;
259
267
  },
260
268
  });
261
- const transport = new CompatibleStdioServerTransport(process.stdin, _safeStdout);
269
+ const transport = new CompatibleStdioServerTransport(process.stdin, safeStdout);
262
270
  await server.connect(transport);
271
+ // Surface the redirect counter on shutdown so users see the volume of
272
+ // stray writes even when individual payloads were truncated/suppressed.
273
+ process.on('exit', () => sentinel.flushSummary());
263
274
  // Graceful shutdown helper
264
275
  let shuttingDown = false;
265
276
  const shutdown = async (exitCode = 0) => {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Stdio capture — leaf module with zero non-`node:` imports.
3
+ *
4
+ * Owns the singleton state that the MCP stdout sentinel needs:
5
+ * - `realStdoutWrite` / `realStderrWrite`: process.stdout.write /
6
+ * process.stderr.write captured at module load, BEFORE anything else
7
+ * can rebind them.
8
+ * - `activeStdoutWrite`: the write handler that silenceStdout/restoreStdout
9
+ * cycles in pool-adapter restore to. Defaults to `realStdoutWrite`;
10
+ * `installGlobalStdoutSentinel` (in stdio-context.ts) registers the
11
+ * sentinel here at MCP startup so silence/restore preserves the sentinel.
12
+ *
13
+ * This module exists separately from `pool-adapter.ts` (which previously
14
+ * owned the same state) so that `cli/mcp.ts`'s static-import closure does
15
+ * NOT transitively pull in `@ladybugdb/core`. Codex's adversarial review on
16
+ * PR #1383 found that the prior structure left a pre-sentinel window where
17
+ * native-module init banners could reach raw stdout: `cli/mcp.ts` →
18
+ * `mcp/stdio-context.ts` → `core/lbug/pool-adapter.ts` → `@ladybugdb/core`.
19
+ * Routing the sentinel state through this leaf module breaks that chain.
20
+ *
21
+ * **Constraint:** keep this module a leaf. No non-`node:` imports — adding
22
+ * any would re-introduce the import-time stdout-corruption hazard.
23
+ */
24
+ type StdoutWrite = typeof process.stdout.write;
25
+ /** Captured at module load, before any rebinding. */
26
+ export declare const realStdoutWrite: StdoutWrite;
27
+ export declare const realStderrWrite: typeof process.stderr.write;
28
+ /**
29
+ * Register a wrapper (e.g., the MCP sentinel) as the active stdout write.
30
+ * silenceStdout/restoreStdout cycles in pool-adapter will preserve the
31
+ * wrapper instead of unwinding to the raw realStdoutWrite. Returns the
32
+ * previous value so callers can chain or restore.
33
+ */
34
+ export declare function setActiveStdoutWrite(fn: StdoutWrite): StdoutWrite;
35
+ /**
36
+ * Read the currently-active stdout write handler. Used by pool-adapter's
37
+ * restoreStdout and watchdog so silence/restore preserves the sentinel.
38
+ */
39
+ export declare function getActiveStdoutWrite(): StdoutWrite;
40
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Stdio capture — leaf module with zero non-`node:` imports.
3
+ *
4
+ * Owns the singleton state that the MCP stdout sentinel needs:
5
+ * - `realStdoutWrite` / `realStderrWrite`: process.stdout.write /
6
+ * process.stderr.write captured at module load, BEFORE anything else
7
+ * can rebind them.
8
+ * - `activeStdoutWrite`: the write handler that silenceStdout/restoreStdout
9
+ * cycles in pool-adapter restore to. Defaults to `realStdoutWrite`;
10
+ * `installGlobalStdoutSentinel` (in stdio-context.ts) registers the
11
+ * sentinel here at MCP startup so silence/restore preserves the sentinel.
12
+ *
13
+ * This module exists separately from `pool-adapter.ts` (which previously
14
+ * owned the same state) so that `cli/mcp.ts`'s static-import closure does
15
+ * NOT transitively pull in `@ladybugdb/core`. Codex's adversarial review on
16
+ * PR #1383 found that the prior structure left a pre-sentinel window where
17
+ * native-module init banners could reach raw stdout: `cli/mcp.ts` →
18
+ * `mcp/stdio-context.ts` → `core/lbug/pool-adapter.ts` → `@ladybugdb/core`.
19
+ * Routing the sentinel state through this leaf module breaks that chain.
20
+ *
21
+ * **Constraint:** keep this module a leaf. No non-`node:` imports — adding
22
+ * any would re-introduce the import-time stdout-corruption hazard.
23
+ */
24
+ /** Captured at module load, before any rebinding. */
25
+ // eslint-disable-next-line no-restricted-syntax -- this IS the captured-real-write infrastructure used by the MCP sentinel
26
+ export const realStdoutWrite = process.stdout.write.bind(process.stdout);
27
+ export const realStderrWrite = process.stderr.write.bind(process.stderr);
28
+ /**
29
+ * The function `restoreStdout` (and the watchdog) in pool-adapter restore
30
+ * *to* when un-silencing. Defaults to the captured real write; the MCP
31
+ * server registers its sentinel here at startMCPServer (via
32
+ * installGlobalStdoutSentinel) so silenceStdout cycles preserve the sentinel
33
+ * instead of unwinding to raw stdout.
34
+ */
35
+ let activeStdoutWrite = realStdoutWrite;
36
+ /**
37
+ * Register a wrapper (e.g., the MCP sentinel) as the active stdout write.
38
+ * silenceStdout/restoreStdout cycles in pool-adapter will preserve the
39
+ * wrapper instead of unwinding to the raw realStdoutWrite. Returns the
40
+ * previous value so callers can chain or restore.
41
+ */
42
+ export function setActiveStdoutWrite(fn) {
43
+ const prev = activeStdoutWrite;
44
+ activeStdoutWrite = fn;
45
+ return prev;
46
+ }
47
+ /**
48
+ * Read the currently-active stdout write handler. Used by pool-adapter's
49
+ * restoreStdout and watchdog so silence/restore preserves the sentinel.
50
+ */
51
+ export function getActiveStdoutWrite() {
52
+ return activeStdoutWrite;
53
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * MCP Stdio Context — AsyncLocalStorage-tagged transport-write detection.
3
+ *
4
+ * The MCP stdio transport writes JSON-RPC frames to stdout. Per spec, the
5
+ * server MUST NOT write anything to stdout that is not a valid MCP message.
6
+ * Stray writes from dependency code corrupt the protocol and present to
7
+ * clients as a hung handshake or `MCP error -32000`.
8
+ *
9
+ * This module provides:
10
+ * - withMcpWrite(fn): runs fn inside an AsyncLocalStorage context tagged
11
+ * `mcp: true`. The transport wraps every send() in this so its writes
12
+ * are recognizable as legitimate.
13
+ * - isMcpWrite(): true when called inside withMcpWrite.
14
+ * - createStdoutSentinel({...}): a write function suitable for installing
15
+ * in a Proxy over process.stdout. Tagged writes pass through to the real
16
+ * stdout; untagged writes are redirected to stderr with a [mcp:stdout-redirect]
17
+ * prefix, truncated to maxBytes per redirect, and rate-limited to maxRedirects
18
+ * per process so a stray loop cannot flood client logs.
19
+ *
20
+ * The sentinel is correctness-by-construction: it identifies legitimate
21
+ * writes by *who* called write(), not by inspecting the bytes. A byte-shape
22
+ * heuristic ("starts with {, ends with \n") would falsely reject Content-Length
23
+ * frames (which start with C and end with }) and misclassify multi-chunk writes.
24
+ */
25
+ export declare function withMcpWrite<T>(fn: () => T): T;
26
+ export declare function isMcpWrite(): boolean;
27
+ type WriteFn = typeof process.stdout.write;
28
+ export interface SentinelOptions {
29
+ realStdoutWrite: WriteFn;
30
+ realStderrWrite: WriteFn;
31
+ /** Maximum bytes of payload to surface per redirect. Defaults to 200. */
32
+ maxBytes?: number;
33
+ /** Maximum number of redirects per process before suppression. Defaults to 10. */
34
+ maxRedirects?: number;
35
+ }
36
+ export interface SentinelStats {
37
+ redirected: number;
38
+ suppressed: number;
39
+ }
40
+ export interface Sentinel {
41
+ write: WriteFn;
42
+ stats: () => SentinelStats;
43
+ flushSummary: () => void;
44
+ }
45
+ export declare function createStdoutSentinel(opts: SentinelOptions): Sentinel;
46
+ export declare function installGlobalStdoutSentinel(): Sentinel;
47
+ export {};
@@ -0,0 +1,145 @@
1
+ /**
2
+ * MCP Stdio Context — AsyncLocalStorage-tagged transport-write detection.
3
+ *
4
+ * The MCP stdio transport writes JSON-RPC frames to stdout. Per spec, the
5
+ * server MUST NOT write anything to stdout that is not a valid MCP message.
6
+ * Stray writes from dependency code corrupt the protocol and present to
7
+ * clients as a hung handshake or `MCP error -32000`.
8
+ *
9
+ * This module provides:
10
+ * - withMcpWrite(fn): runs fn inside an AsyncLocalStorage context tagged
11
+ * `mcp: true`. The transport wraps every send() in this so its writes
12
+ * are recognizable as legitimate.
13
+ * - isMcpWrite(): true when called inside withMcpWrite.
14
+ * - createStdoutSentinel({...}): a write function suitable for installing
15
+ * in a Proxy over process.stdout. Tagged writes pass through to the real
16
+ * stdout; untagged writes are redirected to stderr with a [mcp:stdout-redirect]
17
+ * prefix, truncated to maxBytes per redirect, and rate-limited to maxRedirects
18
+ * per process so a stray loop cannot flood client logs.
19
+ *
20
+ * The sentinel is correctness-by-construction: it identifies legitimate
21
+ * writes by *who* called write(), not by inspecting the bytes. A byte-shape
22
+ * heuristic ("starts with {, ends with \n") would falsely reject Content-Length
23
+ * frames (which start with C and end with }) and misclassify multi-chunk writes.
24
+ */
25
+ import { AsyncLocalStorage } from 'node:async_hooks';
26
+ // Import from the leaf module, NOT `core/lbug/pool-adapter.js`. pool-adapter
27
+ // pulls in `@ladybugdb/core`, which would put the native module in
28
+ // `cli/mcp.ts`'s static-import closure — exactly the pre-sentinel window
29
+ // Codex's adversarial review flagged on PR #1383.
30
+ import { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from './stdio-capture.js';
31
+ const store = new AsyncLocalStorage();
32
+ export function withMcpWrite(fn) {
33
+ return store.run({ mcp: true }, fn);
34
+ }
35
+ export function isMcpWrite() {
36
+ return store.getStore()?.mcp === true;
37
+ }
38
+ const REDIRECT_PREFIX = '[mcp:stdout-redirect] ';
39
+ const STARTUP_WARNING = '[mcp:stdout-redirect] sentinel triggered — stray write redirected to stderr; subsequent redirects logged at exit\n';
40
+ function chunkToBuffer(chunk) {
41
+ if (chunk === undefined || chunk === null)
42
+ return Buffer.alloc(0);
43
+ if (Buffer.isBuffer(chunk))
44
+ return chunk;
45
+ if (typeof chunk === 'string')
46
+ return Buffer.from(chunk, 'utf8');
47
+ // Plain Uint8Array (e.g. from a TypedArray-using producer): copy bytes
48
+ // verbatim instead of falling through to String(chunk), which produces
49
+ // garbage like "1,2,3,...".
50
+ if (chunk instanceof Uint8Array)
51
+ return Buffer.from(chunk);
52
+ return Buffer.from(String(chunk), 'utf8');
53
+ }
54
+ /**
55
+ * Node Writable.write contract: the completion callback, when present, is
56
+ * always the last argument. Match exactly that — don't try to peer past
57
+ * earlier arguments — so future overload shapes (e.g. an options object)
58
+ * do not silently break callback delivery.
59
+ */
60
+ function extractCallback(rest) {
61
+ const last = rest[rest.length - 1];
62
+ return typeof last === 'function' ? last : undefined;
63
+ }
64
+ export function createStdoutSentinel(opts) {
65
+ const maxBytes = opts.maxBytes ?? 200;
66
+ const maxRedirects = opts.maxRedirects ?? 10;
67
+ let redirected = 0;
68
+ let suppressed = 0;
69
+ let warningEmitted = false;
70
+ const stderr = (s) => opts.realStderrWrite(s);
71
+ const write = (chunk, ...rest) => {
72
+ if (isMcpWrite()) {
73
+ return opts.realStdoutWrite(chunk, ...rest);
74
+ }
75
+ if (!warningEmitted) {
76
+ warningEmitted = true;
77
+ stderr(STARTUP_WARNING);
78
+ }
79
+ if (redirected < maxRedirects) {
80
+ redirected += 1;
81
+ const buf = chunkToBuffer(chunk);
82
+ const truncated = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
83
+ stderr(REDIRECT_PREFIX);
84
+ if (truncated.length > 0)
85
+ stderr(truncated);
86
+ if (buf.length > maxBytes) {
87
+ stderr(` (+${buf.length - maxBytes} bytes truncated)`);
88
+ }
89
+ if (truncated.length === 0 || truncated[truncated.length - 1] !== 0x0a) {
90
+ stderr('\n');
91
+ }
92
+ }
93
+ else {
94
+ suppressed += 1;
95
+ }
96
+ // Honor the Writable.write callback contract — fire async to match
97
+ // Node's "next-tick" semantics so callers never observe sync reentry.
98
+ const cb = extractCallback(rest);
99
+ if (cb) {
100
+ process.nextTick(() => cb(null));
101
+ }
102
+ return true;
103
+ };
104
+ return {
105
+ write,
106
+ stats: () => ({ redirected, suppressed }),
107
+ flushSummary: () => {
108
+ if (redirected === 0 && suppressed === 0)
109
+ return;
110
+ stderr(`[mcp:stdout-redirect] summary: ${redirected} redirected, ${suppressed} suppressed beyond cap\n`);
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Install the sentinel as the global stdout interceptor — idempotent.
116
+ *
117
+ * Does three things in order:
118
+ * 1. Creates the sentinel from the captured `realStdoutWrite` / `realStderrWrite`.
119
+ * 2. Replaces `process.stdout.write` with `sentinel.write`.
120
+ * 3. Registers `sentinel.write` as the "active" handler in pool-adapter
121
+ * so silenceStdout/restoreStdout cycles preserve the sentinel
122
+ * instead of unwinding to raw stdout.
123
+ *
124
+ * Idempotent — callers may invoke it multiple times safely (cli/mcp.ts at
125
+ * the top of mcpCommand, and startMCPServer). The earliest caller wins;
126
+ * subsequent calls return the same sentinel handle. Call this BEFORE any
127
+ * other startup work that might emit to stdout: native module loads,
128
+ * `_require()`-style grammar detection, repo registry reads, embedder
129
+ * pipeline initialization. Anything written before the sentinel is in
130
+ * place reaches raw stdout uncaught.
131
+ *
132
+ * Returns the sentinel handle so the earliest caller can register
133
+ * `process.on('exit', sentinel.flushSummary)`.
134
+ */
135
+ let _installedSentinel = null;
136
+ export function installGlobalStdoutSentinel() {
137
+ if (_installedSentinel)
138
+ return _installedSentinel;
139
+ const sentinel = createStdoutSentinel({ realStdoutWrite, realStderrWrite });
140
+ // eslint-disable-next-line no-restricted-syntax -- installing the global sentinel is the API contract
141
+ process.stdout.write = sentinel.write;
142
+ setActiveStdoutWrite(sentinel.write);
143
+ _installedSentinel = sentinel;
144
+ return sentinel;
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.4-rc.80",
3
+ "version": "1.6.4-rc.81",
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",
@@ -44,6 +44,7 @@
44
44
  "dev": "tsx watch src/cli/index.ts",
45
45
  "test": "vitest run",
46
46
  "test:unit": "vitest run test/unit",
47
+ "pretest:integration": "node scripts/build.js",
47
48
  "test:integration": "vitest run test/integration",
48
49
  "test:watch": "vitest",
49
50
  "test:coverage": "vitest run --coverage",
@@ -3,6 +3,17 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { execSync } = require('child_process');
5
5
 
6
+ // Opt-out: skip the native rebuild entirely. Dart parsing becomes
7
+ // unavailable but `npm install gitnexus` finishes much faster on machines
8
+ // without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
9
+ // (read as a string), and any other value all fall through to the rebuild.
10
+ if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
11
+ console.warn(
12
+ '[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
13
+ );
14
+ process.exit(0);
15
+ }
16
+
6
17
  const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
7
18
  const bindingGyp = path.join(dartDir, 'binding.gyp');
8
19
  const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
@@ -34,6 +34,17 @@ const fs = require('fs');
34
34
  const path = require('path');
35
35
  const { execSync } = require('child_process');
36
36
 
37
+ // Opt-out: skip the native rebuild entirely. Proto parsing becomes
38
+ // unavailable but `npm install gitnexus` finishes much faster on machines
39
+ // without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
40
+ // (read as a string), and any other value all fall through to the rebuild.
41
+ if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
42
+ console.warn(
43
+ '[tree-sitter-proto] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Proto parsing will be unavailable until reinstalled without the env var.',
44
+ );
45
+ process.exit(0);
46
+ }
47
+
37
48
  const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
38
49
  const bindingGyp = path.join(protoDir, 'binding.gyp');
39
50
  const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');